String, date & CASE WHEN
Real data is messy. Names have stray spaces, dates arrive as text, categories need to be derived from numbers. This chapter is the toolbox for fixing all of that inside the query, before the data ever reaches pandas.
CASE WHEN comes first because it is the single most useful construct here — it is SQL's if / elif / else.
CASE WHEN
CASE walks its conditions top to bottom and returns the value of the first one that is TRUE. If none match, it returns the ELSE value — or NULL if there is no ELSE.
SELECT enrollment_id, grade, CASE WHEN grade IS NULL THEN 'not graded' WHEN grade >= 90 THEN 'A' WHEN grade >= 80 THEN 'B' WHEN grade >= 70 THEN 'C' WHEN grade >= 60 THEN 'D' ELSE 'F' END AS letter_grade FROM enrollments WHERE grade IS NOT NULL ORDER BY grade DESC LIMIT 6;
| enrollment_id | grade | letter_grade |
|---|---|---|
| 135 | 100 | A |
| 322 | 100 | A |
| 500 | 100 | A |
| 699 | 100 | A |
| 814 | 100 | A |
| 1 096 | 100 | A |
6 rows
1 ms
:::gotcha ⚠️ Order matters — and it is why you don't need upper bounds
Students often write WHEN grade >= 80 AND grade < 90 THEN 'B'. The upper bound is unnecessary: by the time SQL reaches that line, every grade of 90 or more has already matched and left. Writing the conditions from highest to lowest makes the whole thing simpler.
Now reverse the order and watch it break: put WHEN grade >= 60 THEN 'D' first, and every passing grade becomes a D, because 100 also satisfies >= 60. Have students make this mistake once on purpose.
:::
grade IS NULL is the first branch on purpose. If you leave it out, ungraded enrollments fall through every numeric comparison (all UNKNOWN) and land in ELSE — so a student who has not been graded yet is reported as an F. That is a genuinely damaging bug, and it is invisible.
The two forms of CASE
-- Searched CASE: any condition. Use this 95% of the time. CASE WHEN status = 'completed' THEN 'done' WHEN status = 'active' THEN 'in progress' ELSE 'other' END -- Simple CASE: shorter, but only tests equality against one expression CASE status WHEN 'completed' THEN 'done' WHEN 'active' THEN 'in progress' ELSE 'other' END
:::gotcha ⚠️ Simple CASE cannot match NULL
CASE status WHEN NULL THEN ... never fires, because it compiles to status = NULL — UNKNOWN, forever. Only the searched form can test WHEN status IS NULL. When NULLs are possible, use the searched form.
:::
CASE in ORDER BY — custom sort order
Alphabetical order is rarely the order a human wants. CASE lets you define your own:
SELECT enrollment_id, status FROM enrollments ORDER BY CASE status WHEN 'active' THEN 1 WHEN 'completed' THEN 2 WHEN 'failed' THEN 3 WHEN 'dropped' THEN 4 ELSE 5 END, enrollment_id;
| enrollment_id | status |
|---|---|
| 2 | active |
| 4 | active |
| 5 | active |
| 7 | active |
| 10 | active |
| 22 | active |
| 23 | active |
| 24 | active |
| 25 | active |
| 27 | active |
| 31 | active |
| 40 | active |
| 41 | active |
| 42 | active |
| 46 | active |
… first 15 of 2500 rows
2500 rows
2 ms
Another common trick — force NULLs to the end regardless of the database's default:
ORDER BY CASE WHEN city IS NULL THEN 1 ELSE 0 END, city;
CASE inside aggregates (pivot)
This is the most valuable pattern in the whole chapter. Putting CASE inside SUM or COUNT turns rows into columns — a pivot table, in plain SQL.
SELECT course_id, COUNT(*) AS total, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed, SUM(CASE WHEN status = 'dropped' THEN 1 ELSE 0 END) AS dropped FROM enrollments GROUP BY course_id ORDER BY course_id;
| course_id | total | completed | active | failed | dropped |
|---|---|---|---|---|---|
| 1 | 193 | 130 | 41 | 2 | 20 |
| 2 | 186 | 134 | 26 | 12 | 14 |
| 3 | 174 | 81 | 39 | 47 | 7 |
| 4 | 167 | 111 | 36 | 8 | 12 |
| 5 | 167 | 110 | 33 | 15 | 9 |
| 6 | 174 | 114 | 34 | 4 | 22 |
| 7 | 165 | 120 | 32 | 2 | 11 |
| 8 | 170 | 118 | 27 | 13 | 12 |
| 9 | 161 | 112 | 32 | 6 | 11 |
| 10 | 179 | 139 | 21 | 8 | 11 |
| 11 | 200 | 116 | 45 | 24 | 15 |
| 13 | 187 | 123 | 31 | 4 | 29 |
| 14 | 189 | 125 | 38 | 10 | 16 |
| 15 | 188 | 101 | 37 | 37 | 13 |
14 rows
1 ms
course_idtotalcompletedactivefaileddropped
152120 255000 332001 432100 541300
Compare this with GROUP BY course_id, status from Chapter 4: that gives one row per combination and misses the empty ones. This gives a proper grid, with real zeros. It is what a manager actually wants to look at.
```sql run -- COUNT(CASE ...) with no ELSE — NULL is not counted, so this also works COUNT(CASE WHEN status = 'failed' THEN 1 END) AS failed
-- AVG over a subset — note NO "ELSE 0", or the zeros would drag the average down AVG(CASE WHEN status = 'completed' THEN grade END) AS avg_completed_grade ```
The ELSE 0 distinction is the trap: correct for SUM/counting, wrong for AVG.
The same pattern gives conditional percentages — the pass-rate idiom from Chapter 4:
SELECT course_id, COUNT(*) AS graded, ROUND(100.0 * SUM(CASE WHEN grade >= 60 THEN 1 ELSE 0 END) / COUNT(*), 1) AS pass_pct FROM enrollments WHERE grade IS NOT NULL GROUP BY course_id;
| course_id | graded | pass_pct |
|---|---|---|
| 1 | 132 | 98.5 |
| 2 | 146 | 91.8 |
| 3 | 128 | 63.3 |
| 4 | 119 | 93.3 |
| 5 | 125 | 88 |
| 6 | 118 | 96.6 |
| 7 | 122 | 98.4 |
| 8 | 131 | 90.1 |
| 9 | 118 | 94.9 |
| 10 | 147 | 94.6 |
| 11 | 140 | 82.9 |
| 13 | 127 | 96.9 |
| 14 | 135 | 92.6 |
| 15 | 138 | 73.2 |
14 rows
1 ms
String functions
FunctionDoesExample → result
UPPER(s) / LOWER(s)Change caseUPPER('aziza') → AZIZA
LENGTH(s)Number of charactersLENGTH('Aziza') → 5
SUBSTR(s, start, len)Extract part (1-indexed!)SUBSTR('Karimova',1,3) → Kar
REPLACE(s, from, to)Replace all occurrencesREPLACE('a-b-c','-','.') → a.b.c
TRIM(s) / LTRIM / RTRIMRemove surrounding spacesTRIM(' hi ') → hi
INSTR(s, sub)Position of substring, 0 if absentINSTR('a@b.uz','@') → 2
a || bConcatenate'A' || 'B' → AB
SELECT first_name || ' ' || last_name AS full_name, SUBSTR(first_name,1,1) || SUBSTR(last_name,1,1) AS initials, LOWER(first_name) || '.' || LOWER(last_name) || '@school.uz' AS email, LENGTH(first_name || last_name) AS name_length FROM students LIMIT 4;
| full_name | initials | name_length | |
|---|---|---|---|
| Shahnoza Alimova | SA | shahnoza.alimova@school.uz | 15 |
| Doston Umarov | DU | doston.umarov@school.uz | 12 |
| Zulfiya Yusupova | ZY | zulfiya.yusupova@school.uz | 15 |
| Kamola Karimova | KK | kamola.karimova@school.uz | 14 |
4 rows
0 ms
:::gotcha ⚠️ SQL strings start at 1, not 0
Every Python programmer gets this wrong once. SUBSTR(s, 1, 3) is the first three characters — SUBSTR(s, 0, 3) does something surprising and different. There is no zero-based indexing anywhere in SQL.
:::
Dialect note: string function names are one of the least portable parts of SQL.
| Task | SQLite | PostgreSQL | MySQL | SQL Server |
|---|---|---|---|---|
| Concatenate | || |
|| |
CONCAT() |
+ |
| Substring | SUBSTR |
SUBSTRING |
SUBSTRING |
SUBSTRING |
| Length | LENGTH |
LENGTH |
LENGTH |
LEN |
| Position | INSTR |
POSITION/STRPOS |
LOCATE |
CHARINDEX |
| Split into rows | — | string_to_table |
SUBSTRING_INDEX |
STRING_SPLIT |
Cleaning messy text
The two most common real-world text problems, and how to fix both in the WHERE clause:
-- Problem 1: invisible leading/trailing spaces. -- 'Tashkent ' <> 'Tashkent', so this row silently never matches. SELECT * FROM students WHERE TRIM(city) = 'Tashkent'; -- Problem 2: inconsistent capitalisation ('TASHKENT', 'tashkent', 'Tashkent') SELECT * FROM students WHERE LOWER(TRIM(city)) = 'tashkent'; -- Both at once, as a cleaned output column SELECT city AS raw, UPPER(SUBSTR(TRIM(LOWER(city)),1,1)) || SUBSTR(TRIM(LOWER(city)),2) AS cleaned -- 'Tashkent' FROM students;
Result 1
| student_id | first_name | last_name | gender | city | birth_date | enrolled_date |
|---|---|---|---|---|---|---|
| 8 | Madina | Rahimova | F | Tashkent | 2000-05-29 | 2025-02-09 |
| 12 | Rustam | Toshev | M | Tashkent | 2001-02-12 | 2025-09-08 |
| 19 | Aziza | Saidova | F | Tashkent | 2003-12-08 | 2025-02-10 |
| 20 | Aziza | Karimova | F | Tashkent | 2003-01-18 | 2025-09-03 |
| 27 | Zulfiya | Karimova | F | Tashkent | 2002-06-30 | 2025-02-13 |
| 30 | Gulnora | Yusupova | F | Tashkent | 2001-11-13 | 2025-02-12 |
| 31 | Dilnoza | Saidova | F | Tashkent | 1998-09-26 | 2025-09-01 |
| 39 | Aziz | Toshev | M | Tashkent | 2004-06-28 | 2025-09-03 |
| 40 | Aziza | Alimova | F | Tashkent | 2000-07-21 | 2025-02-05 |
| 43 | Malika | Ergasheva | F | Tashkent | 2001-02-13 | 2025-09-04 |
| 44 | Shohrux | Rahimov | M | Tashkent | 2004-04-24 | 2025-09-07 |
| 45 | Nilufar | Nazarova | F | Tashkent | 2001-12-24 | 2025-09-11 |
| 50 | Otabek | Alimov | M | Tashkent | 2002-12-30 | 2025-02-10 |
| 56 | Sardor | Saidov | M | Tashkent | 2001-06-11 | 2025-09-01 |
| 64 | Gulnora | Alimova | F | Tashkent | 2000-06-20 | 2025-02-19 |
… first 15 of 82 rows
82 rows
Result 2
| student_id | first_name | last_name | gender | city | birth_date | enrolled_date |
|---|---|---|---|---|---|---|
| 8 | Madina | Rahimova | F | Tashkent | 2000-05-29 | 2025-02-09 |
| 12 | Rustam | Toshev | M | Tashkent | 2001-02-12 | 2025-09-08 |
| 19 | Aziza | Saidova | F | Tashkent | 2003-12-08 | 2025-02-10 |
| 20 | Aziza | Karimova | F | Tashkent | 2003-01-18 | 2025-09-03 |
| 27 | Zulfiya | Karimova | F | Tashkent | 2002-06-30 | 2025-02-13 |
| 30 | Gulnora | Yusupova | F | Tashkent | 2001-11-13 | 2025-02-12 |
| 31 | Dilnoza | Saidova | F | Tashkent | 1998-09-26 | 2025-09-01 |
| 39 | Aziz | Toshev | M | Tashkent | 2004-06-28 | 2025-09-03 |
| 40 | Aziza | Alimova | F | Tashkent | 2000-07-21 | 2025-02-05 |
| 43 | Malika | Ergasheva | F | Tashkent | 2001-02-13 | 2025-09-04 |
| 44 | Shohrux | Rahimov | M | Tashkent | 2004-04-24 | 2025-09-07 |
| 45 | Nilufar | Nazarova | F | Tashkent | 2001-12-24 | 2025-09-11 |
| 50 | Otabek | Alimov | M | Tashkent | 2002-12-30 | 2025-02-10 |
| 56 | Sardor | Saidov | M | Tashkent | 2001-06-11 | 2025-09-01 |
| 64 | Gulnora | Alimova | F | Tashkent | 2000-06-20 | 2025-02-19 |
… first 15 of 82 rows
82 rows
Result 3
| raw | cleaned |
|---|---|
| Bukhara | Bukhara |
| Fergana | Fergana |
| Samarkand | Samarkand |
| Samarkand | Samarkand |
| Namangan | Namangan |
| Samarkand | Samarkand |
| Karshi | Karshi |
| Tashkent | Tashkent |
| Bukhara | Bukhara |
| Andijan | Andijan |
| Karshi | Karshi |
| Tashkent | Tashkent |
| Namangan | Namangan |
| Andijan | Andijan |
| Namangan | Namangan |
… first 15 of 300 rows
300 rows
2 ms
:::gotcha ⚠️ Cleaning in WHERE costs you the index
WHERE LOWER(TRIM(city)) = 'tashkent' is correct but forces a full scan (Chapter 3's non-sargable rule). For a one-off analysis that is fine. For a query that runs constantly, the real fix is to clean the data on the way in — an UPDATE once (Chapter 10), or a CHECK constraint that refuses dirty values in the first place.
:::
A quick way to find dirty values before you decide what to do:
SELECT city, COUNT(*) AS n, CASE WHEN city <> TRIM(city) THEN 'has spaces' ELSE 'clean' END AS issue FROM students GROUP BY city ORDER BY n DESC;
| city | n | issue |
|---|---|---|
| Tashkent | 82 | clean |
| Samarkand | 40 | clean |
| Bukhara | 38 | clean |
| Fergana | 36 | clean |
| Namangan | 28 | clean |
| Andijan | 24 | clean |
| Nukus | 22 | clean |
| Karshi | 22 | clean |
| None | 8 | clean |
9 rows
0 ms
Date and time functions
SQLite stores dates as 'YYYY-MM-DD' text and provides five functions to work with them.
| Function | Returns | Example |
|---|---|---|
DATE(x) |
YYYY-MM-DD |
DATE('now') |
DATETIME(x) |
YYYY-MM-DD HH:MM:SS |
DATETIME('now','localtime') |
strftime(fmt, x) |
Any formatted part | strftime('%Y-%m', d) |
JULIANDAY(x) |
A number of days — for subtraction | JULIANDAY(a) - JULIANDAY(b) |
| Modifiers | Shift a date | DATE(d, '+1 month', '-1 day') |
| strftime code | Gives | For 2026-02-14 |
|---|---|---|
%Y |
Year | 2026 |
%m |
Month (01–12) | 02 |
%d |
Day | 14 |
%Y-%m |
Year-month — the reporting workhorse | 2026-02 |
%w |
Weekday (0 = Sunday) | 6 |
%j |
Day of year | 045 |
%W |
Week of year | 06 |
SELECT enrolled_at, strftime('%Y', enrolled_at) AS year, strftime('%Y-%m', enrolled_at) AS year_month, strftime('%w', enrolled_at) AS weekday_num, CASE strftime('%w', enrolled_at) WHEN '0' THEN 'Sunday' WHEN '1' THEN 'Monday' WHEN '2' THEN 'Tuesday' WHEN '3' THEN 'Wednesday' WHEN '4' THEN 'Thursday' WHEN '5' THEN 'Friday' ELSE 'Saturday' END AS weekday FROM enrollments WHERE enrollment_id IN (1, 13, 19);
| enrolled_at | year | year_month | weekday_num | weekday |
|---|---|---|---|---|
| 2025-02-18 | 2025 | 2025-02 | 2 | Tuesday |
| 2025-02-13 | 2025 | 2025-02 | 4 | Thursday |
| 2025-02-14 | 2025 | 2025-02 | 5 | Friday |
3 rows
0 ms
SELECT DATE('2026-02-14', '+7 days') AS week_later, -- 2026-02-21 DATE('2026-02-14', '-1 month') AS month_earlier, -- 2026-01-14 DATE('2026-02-14', 'start of month') AS month_start, -- 2026-02-01 DATE('2026-02-14', 'start of month', '+1 month', '-1 day') AS month_end, -- 2026-02-28 DATE('2026-02-14', 'start of year') AS year_start; -- 2026-01-01
| week_later | month_earlier | month_start | month_end | year_start |
|---|---|---|---|---|
| 2026-02-21 | 2026-01-14 | 2026-02-01 | 2026-02-28 | 2026-01-01 |
1 row
0 ms
That month_end chain is the standard way to get the last day of any month without hard-coding 28/30/31 or worrying about leap years.
Date arithmetic — age and gaps
Subtracting dates needs JULIANDAY, which converts a date to a plain number of days.
SELECT first_name, birth_date, CAST((JULIANDAY('2026-09-01') - JULIANDAY(birth_date)) / 365.25 AS INTEGER) AS age FROM students ORDER BY age DESC;
| first_name | birth_date | age |
|---|---|---|
| Gulnora | 1998-03-21 | 28 |
| Aziz | 1998-05-23 | 28 |
| Gulnora | 1998-08-23 | 28 |
| Otabek | 1998-05-31 | 28 |
| Shohrux | 1998-04-07 | 28 |
| Ulug'bek | 1998-03-05 | 28 |
| Zulfiya | 1998-06-24 | 28 |
| Doston | 1998-05-18 | 28 |
| Doston | 1998-05-15 | 28 |
| Kamola | 1998-04-15 | 28 |
| Ulug'bek | 1998-08-09 | 28 |
| Zulfiya | 1998-05-13 | 28 |
| Kamola | 1998-04-14 | 28 |
| Shahnoza | 1998-05-29 | 28 |
| Akmal | 1998-07-30 | 28 |
… first 15 of 300 rows
300 rows
0 ms
:::gotcha ⚠️ Why 365.25 and why CAST, not ROUND
365.25 accounts for leap years. CAST(... AS INTEGER) truncates, which is what "age" means — you are 21 until the day you turn 22, not "rounded to 22" six months early. Using ROUND here makes half your students a year older than they are. Note also that the reference date is written literally rather than as DATE('now'), so the numbers in this table stay reproducible for your class.
:::
Gaps between events, combining JULIANDAY with the LAG from Chapter 7:
SELECT student_id, enrolled_at, LAG(enrolled_at) OVER (PARTITION BY student_id ORDER BY enrolled_at) AS previous, CAST(JULIANDAY(enrolled_at) - JULIANDAY(LAG(enrolled_at) OVER (PARTITION BY student_id ORDER BY enrolled_at)) AS INTEGER) AS days_since_last FROM enrollments WHERE student_id IN (1, 3) ORDER BY student_id, enrolled_at;
| student_id | enrolled_at | previous | days_since_last |
|---|---|---|---|
| 1 | 2025-09-06 | None | NaN |
| 1 | 2025-09-07 | 2025-09-06 | 1 |
| 1 | 2025-09-09 | 2025-09-07 | 2 |
| 1 | 2025-09-13 | 2025-09-09 | 4 |
| 1 | 2025-09-13 | 2025-09-13 | 0 |
| 1 | 2025-09-17 | 2025-09-13 | 4 |
| 1 | 2025-09-26 | 2025-09-17 | 9 |
| 3 | 2025-02-17 | None | NaN |
| 3 | 2025-02-19 | 2025-02-17 | 2 |
| 3 | 2025-02-19 | 2025-02-19 | 0 |
| 3 | 2025-02-20 | 2025-02-19 | 1 |
| 3 | 2025-02-24 | 2025-02-20 | 4 |
| 3 | 2025-03-04 | 2025-02-24 | 8 |
| 3 | 2025-03-05 | 2025-03-04 | 1 |
| 3 | 2025-03-08 | 2025-03-05 | 3 |
… first 15 of 16 rows
16 rows
0 ms
student_idenrolled_atpreviousdays_since_last
12025-09-05NULLNULL 12025-09-052025-09-050 12026-02-032025-09-05151 32025-09-18NULLNULL 32026-02-052025-09-18140 32026-02-052026-02-050
CAST and type conversion
SELECT CAST('42' AS INTEGER) AS text_to_int, -- 42 CAST(3.99 AS INTEGER) AS float_to_int, -- 3 (truncated, not rounded!) CAST(7 AS REAL) AS int_to_real, -- 7.0 CAST(42 AS TEXT) AS int_to_text, -- '42' CAST('abc' AS INTEGER) AS bad_cast; -- 0 in SQLite, ERROR in PostgreSQL
| text_to_int | float_to_int | int_to_real | int_to_text | bad_cast |
|---|---|---|---|---|
| 42 | 3 | 7 | 42 | 0 |
1 row
0 ms
:::gotcha ⚠️ CAST to INTEGER truncates; failed casts differ by database
CAST(3.99 AS INTEGER) is 3, not 4. Use ROUND when you mean rounding. And a bad cast is silent in SQLite (returns 0) but raises an error in PostgreSQL — so a data-cleaning query that "works" locally can fail in production, or worse, quietly turn every unparseable value into a zero.
:::
The most common practical use is the one from Chapter 2 — forcing real division:
SELECT 7 / 2 AS integer_division, -- 3 7 * 1.0 / 2 AS trick_multiply, -- 3.5 CAST(7 AS REAL) / 2 AS explicit_cast; -- 3.5
| integer_division | trick_multiply | explicit_cast |
|---|---|---|
| 3 | 3.5 | 3.5 |
1 row
0 ms
Math and rounding
FunctionDoesExample → result
ROUND(x, n)Round to n decimalsROUND(79.428, 2) → 79.43
ABS(x)Absolute valueABS(-15) → 15
x % yRemainder17 % 5 → 2
MIN(a,b) / MAX(a,b)Smaller / larger of the argumentsMAX(10, 20) → 20
ABS, POWER, SQRTStandard mathsSQRT(16) → 4.0
:::gotcha ⚠️ MIN means two different things
MIN(grade) with one argument is the aggregate — the lowest grade across many rows. MIN(a, b) with two arguments is a scalar function comparing two values in the same row. Same name, completely different behaviour. (PostgreSQL avoids the confusion by calling the scalar version LEAST/GREATEST.)
:::
Practice set
-- 1. Letter grade for every graded enrollment SELECT enrollment_id, grade, CASE WHEN grade >= 90 THEN 'A' WHEN grade >= 80 THEN 'B' WHEN grade >= 70 THEN 'C' WHEN grade >= 60 THEN 'D' ELSE 'F' END AS letter FROM enrollments WHERE grade IS NOT NULL ORDER BY grade DESC; -- 2. How many of each letter grade? SELECT CASE WHEN grade >= 90 THEN 'A' WHEN grade >= 80 THEN 'B' WHEN grade >= 70 THEN 'C' WHEN grade >= 60 THEN 'D' ELSE 'F' END AS letter, COUNT(*) AS n FROM enrollments WHERE grade IS NOT NULL GROUP BY letter ORDER BY letter; -- 3. Status pivot per course SELECT course_id, SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) AS completed, SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) AS failed FROM enrollments GROUP BY course_id; -- 4. Generated email addresses SELECT LOWER(first_name) || '.' || LOWER(last_name) || '@school.uz' AS email FROM students; -- 5. Age of every student SELECT first_name, CAST((JULIANDAY('2026-09-01') - JULIANDAY(birth_date))/365.25 AS INTEGER) AS age FROM students ORDER BY age DESC; -- 6. Price band per course SELECT title, price, CASE WHEN price >= 2500000 THEN 'premium' WHEN price >= 1400000 THEN 'standard' ELSE 'basic' END AS price_band FROM courses ORDER BY price DESC; -- 7. Enrollments per weekday SELECT strftime('%w', enrolled_at) AS weekday_num, COUNT(*) AS n FROM enrollments GROUP BY weekday_num ORDER BY weekday_num; -- 8. Students grouped into age bands SELECT CASE WHEN CAST((JULIANDAY('2026-09-01')-JULIANDAY(birth_date))/365.25 AS INTEGER) <= 21 THEN '21 and under' ELSE '22 and over' END AS age_band, COUNT(*) AS n FROM students GROUP BY age_band;
Result 1
| enrollment_id | grade | letter |
|---|---|---|
| 135 | 100 | A |
| 322 | 100 | A |
| 500 | 100 | A |
| 699 | 100 | A |
| 814 | 100 | A |
| 1 096 | 100 | A |
| 1 202 | 100 | A |
| 1 832 | 100 | A |
| 1 862 | 100 | A |
| 2 126 | 100 | A |
| 2 128 | 100 | A |
| 2 161 | 100 | A |
| 2 377 | 100 | A |
| 2 424 | 100 | A |
| 483 | 99 | A |
… first 15 of 1826 rows
1826 rows
Result 2
| letter | n |
|---|---|
| A | 115 |
| B | 379 |
| C | 665 |
| D | 475 |
| F | 192 |
5 rows
Result 3
| course_id | completed | active | failed |
|---|---|---|---|
| 1 | 130 | 41 | 2 |
| 2 | 134 | 26 | 12 |
| 3 | 81 | 39 | 47 |
| 4 | 111 | 36 | 8 |
| 5 | 110 | 33 | 15 |
| 6 | 114 | 34 | 4 |
| 7 | 120 | 32 | 2 |
| 8 | 118 | 27 | 13 |
| 9 | 112 | 32 | 6 |
| 10 | 139 | 21 | 8 |
| 11 | 116 | 45 | 24 |
| 13 | 123 | 31 | 4 |
| 14 | 125 | 38 | 10 |
| 15 | 101 | 37 | 37 |
14 rows
Result 4
| shahnoza.alimova@school.uz |
| doston.umarov@school.uz |
| zulfiya.yusupova@school.uz |
| kamola.karimova@school.uz |
| gulnora.saidova@school.uz |
| bekzod.yusupov@school.uz |
| doston.alimov@school.uz |
| madina.rahimova@school.uz |
| sardor.alimov@school.uz |
| dilnoza.alimova@school.uz |
| jasur.ergashev@school.uz |
| rustam.toshev@school.uz |
| bekzod.qodirov@school.uz |
| otabek.saidov@school.uz |
| madina.umarova@school.uz |
… first 15 of 300 rows
300 rows
Result 5
| first_name | age |
|---|---|
| Gulnora | 28 |
| Aziz | 28 |
| Gulnora | 28 |
| Otabek | 28 |
| Shohrux | 28 |
| Ulug'bek | 28 |
| Zulfiya | 28 |
| Doston | 28 |
| Doston | 28 |
| Kamola | 28 |
| Ulug'bek | 28 |
| Zulfiya | 28 |
| Kamola | 28 |
| Shahnoza | 28 |
| Akmal | 28 |
… first 15 of 300 rows
300 rows
Result 6
| title | price | price_band |
|---|---|---|
| Deep Learning | 1 550 000 | standard |
| Project Management | 1 500 000 | standard |
| Financial Analysis | 1 500 000 | standard |
| Machine Learning | 1 450 000 | standard |
| Probability | 1 250 000 | basic |
| Python Basics | 1 200 000 | basic |
| Statistics 101 | 1 200 000 | basic |
| Linear Algebra | 1 200 000 | basic |
| Web Development | 1 150 000 | basic |
| Java Basics | 1 150 000 | basic |
| Excel for Business | 1 100 000 | basic |
| Graphic Design | 950 000 | basic |
| Data Visualization | 900 000 | basic |
| UX Design | 850 000 | basic |
| SQL for Analytics | 800 000 | basic |
15 rows
Result 7
| weekday_num | n |
|---|---|
| 0 | 345 |
| 1 | 327 |
| 2 | 358 |
| 3 | 405 |
| 4 | 358 |
| 5 | 331 |
| 6 | 376 |
7 rows
Result 8
| age_band | n |
|---|---|
| 21 and under | 13 |
| 22 and over | 287 |
2 rows
5 ms
Interview questions
Q: How does CASE decide which branch to use?
It evaluates conditions top to bottom and returns the first that is TRUE, so order matters. If nothing matches and there is no ELSE, it returns NULL.
Q: How do you build a pivot table in plain SQL?
SUM(CASE WHEN col = 'value' THEN 1 ELSE 0 END) AS value, one such expression per output column, with GROUP BY on the row dimension.
Q: Why is ELSE 0 wrong inside AVG(CASE ...)?
Because it converts non-matching rows into zeros that enter the average, instead of being excluded. Omit the ELSE so they become NULL and AVG skips them.
Q: Difference between simple and searched CASE?
Simple CASE expr WHEN v THEN ... only tests equality against one expression and cannot match NULL. Searched CASE WHEN condition THEN ... allows any condition, including IS NULL.
Q: Is SUBSTR 0-based or 1-based?
1-based. The first character is position 1.
Q: How do you get the last day of a month?
Go to the start of the month, add a month, subtract a day: DATE(d,'start of month','+1 month','-1 day'). Never hard-code 30 or 31.
Q: What is wrong with WHERE UPPER(name) = 'ALI' on a large table?
The function on the column makes it non-sargable, so no index can be used. Fix the data or add a functional index rather than wrapping the column at query time.
Q: CAST(3.99 AS INTEGER) — 3 or 4?
CASTtruncates. UseROUNDif you want 4.
Summary
CASE WHENis SQL's if/else. Conditions are checked in order, so write them high-to-low and put the NULL check first — otherwise ungraded rows silently become F.- Simple
CASEcannot match NULL; the searched form can. CASEinORDER BYgives custom sort orders and controls NULL placement.SUM(CASE WHEN ... THEN 1 ELSE 0 END)is the pivot idiom — rows become columns, with real zeros. Drop theELSE 0when the aggregate isAVG.- String work:
||,UPPER/LOWER,SUBSTR(1-based),TRIM,REPLACE,LENGTH,INSTR. Names differ a lot between databases. TRIM+LOWERfixes most dirty text, but cleaning insideWHEREcosts the index — clean the stored data instead.- Dates:
strftimeto extract,DATE(d, modifier)to shift,JULIANDAYto subtract.'start of month','+1 month','-1 day'gives month-end safely. - Age =
CAST(days / 365.25 AS INTEGER)— truncate, never round. CASTto INTEGER truncates; a bad cast is silently 0 in SQLite but an error in PostgreSQL.
Next: Chapter 9 — stacking query results on top of each other with UNION, INTERSECT and EXCEPT.
Questions about this chapter?
The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.