Skip to content

String, date & CASE WHEN

Chapter 8 15 code blocks 4004 words

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.

SyntaxCASE WHEN condition1 THEN result1 WHEN condition2 THEN result2 ELSE default_result END
sql15 lines
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_idgradeletter_grade
135100A
322100A
500100A
699100A
814100A
1 096100A

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. :::

⚠️ Put the NULL check first

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

python10 lines
-- 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:

sql10 lines
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_idstatus
2active
4active
5active
7active
10active
22active
23active
24active
25active
27active
31active
40active
41active
42active
46active

… 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.

sql10 lines
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_idtotalcompletedactivefaileddropped
119313041220
2186134261214
31748139477
416711136812
516711033159
617411434422
716512032211
8170118271312
916111232611
1017913921811
11200116452415
1318712331429
14189125381016
15188101373713

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.

💡 Two variations worth knowing

```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:

sql6 lines
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_idgradedpass_pct
113298.5
214691.8
312863.3
411993.3
512588
611896.6
712298.4
813190.1
911894.9
1014794.6
1114082.9
1312796.9
1413592.6
1513873.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

sql7 lines
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_nameinitialsemailname_length
Shahnoza AlimovaSAshahnoza.alimova@school.uz15
Doston UmarovDUdoston.umarov@school.uz12
Zulfiya YusupovaZYzulfiya.yusupova@school.uz15
Kamola KarimovaKKkamola.karimova@school.uz14

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:

python13 lines
-- 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_idfirst_namelast_namegendercitybirth_dateenrolled_date
8MadinaRahimovaFTashkent2000-05-292025-02-09
12RustamToshevMTashkent2001-02-122025-09-08
19AzizaSaidovaFTashkent2003-12-082025-02-10
20AzizaKarimovaFTashkent2003-01-182025-09-03
27ZulfiyaKarimovaFTashkent2002-06-302025-02-13
30GulnoraYusupovaFTashkent2001-11-132025-02-12
31DilnozaSaidovaFTashkent1998-09-262025-09-01
39AzizToshevMTashkent2004-06-282025-09-03
40AzizaAlimovaFTashkent2000-07-212025-02-05
43MalikaErgashevaFTashkent2001-02-132025-09-04
44ShohruxRahimovMTashkent2004-04-242025-09-07
45NilufarNazarovaFTashkent2001-12-242025-09-11
50OtabekAlimovMTashkent2002-12-302025-02-10
56SardorSaidovMTashkent2001-06-112025-09-01
64GulnoraAlimovaFTashkent2000-06-202025-02-19

… first 15 of 82 rows

82 rows

Result 2

student_idfirst_namelast_namegendercitybirth_dateenrolled_date
8MadinaRahimovaFTashkent2000-05-292025-02-09
12RustamToshevMTashkent2001-02-122025-09-08
19AzizaSaidovaFTashkent2003-12-082025-02-10
20AzizaKarimovaFTashkent2003-01-182025-09-03
27ZulfiyaKarimovaFTashkent2002-06-302025-02-13
30GulnoraYusupovaFTashkent2001-11-132025-02-12
31DilnozaSaidovaFTashkent1998-09-262025-09-01
39AzizToshevMTashkent2004-06-282025-09-03
40AzizaAlimovaFTashkent2000-07-212025-02-05
43MalikaErgashevaFTashkent2001-02-132025-09-04
44ShohruxRahimovMTashkent2004-04-242025-09-07
45NilufarNazarovaFTashkent2001-12-242025-09-11
50OtabekAlimovMTashkent2002-12-302025-02-10
56SardorSaidovMTashkent2001-06-112025-09-01
64GulnoraAlimovaFTashkent2000-06-202025-02-19

… first 15 of 82 rows

82 rows

Result 3

rawcleaned
BukharaBukhara
FerganaFergana
SamarkandSamarkand
SamarkandSamarkand
NamanganNamangan
SamarkandSamarkand
KarshiKarshi
TashkentTashkent
BukharaBukhara
AndijanAndijan
KarshiKarshi
TashkentTashkent
NamanganNamangan
AndijanAndijan
NamanganNamangan

… 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:

sql5 lines
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;
citynissue
Tashkent82clean
Samarkand40clean
Bukhara38clean
Fergana36clean
Namangan28clean
Andijan24clean
Nukus22clean
Karshi22clean
None8clean

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
sql13 lines
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_atyearyear_monthweekday_numweekday
2025-02-1820252025-022Tuesday
2025-02-1320252025-024Thursday
2025-02-1420252025-025Friday

3 rows

0 ms

sql6 lines
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_latermonth_earliermonth_startmonth_endyear_start
2026-02-212026-01-142026-02-012026-02-282026-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.

sql6 lines
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_namebirth_dateage
Gulnora1998-03-2128
Aziz1998-05-2328
Gulnora1998-08-2328
Otabek1998-05-3128
Shohrux1998-04-0728
Ulug'bek1998-03-0528
Zulfiya1998-06-2428
Doston1998-05-1828
Doston1998-05-1528
Kamola1998-04-1528
Ulug'bek1998-08-0928
Zulfiya1998-05-1328
Kamola1998-04-1428
Shahnoza1998-05-2928
Akmal1998-07-3028

… 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:

sql10 lines
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_idenrolled_atpreviousdays_since_last
12025-09-06NoneNaN
12025-09-072025-09-061
12025-09-092025-09-072
12025-09-132025-09-094
12025-09-132025-09-130
12025-09-172025-09-134
12025-09-262025-09-179
32025-02-17NoneNaN
32025-02-192025-02-172
32025-02-192025-02-190
32025-02-202025-02-191
32025-02-242025-02-204
32025-03-042025-02-248
32025-03-052025-03-041
32025-03-082025-03-053

… 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

sql6 lines
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_intfloat_to_intint_to_realint_to_textbad_cast
4237420

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:

sql3 lines
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_divisiontrick_multiplyexplicit_cast
33.53.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

python46 lines
-- 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_idgradeletter
135100A
322100A
500100A
699100A
814100A
1 096100A
1 202100A
1 832100A
1 862100A
2 126100A
2 128100A
2 161100A
2 377100A
2 424100A
48399A

… first 15 of 1826 rows

1826 rows

Result 2

lettern
A115
B379
C665
D475
F192

5 rows

Result 3

course_idcompletedactivefailed
1130412
21342612
3813947
4111368
51103315
6114344
7120322
81182713
9112326
10139218
111164524
13123314
141253810
151013737

14 rows

Result 4

email
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_nameage
Gulnora28
Aziz28
Gulnora28
Otabek28
Shohrux28
Ulug'bek28
Zulfiya28
Doston28
Doston28
Kamola28
Ulug'bek28
Zulfiya28
Kamola28
Shahnoza28
Akmal28

… first 15 of 300 rows

300 rows

Result 6

titlepriceprice_band
Deep Learning1 550 000standard
Project Management1 500 000standard
Financial Analysis1 500 000standard
Machine Learning1 450 000standard
Probability1 250 000basic
Python Basics1 200 000basic
Statistics 1011 200 000basic
Linear Algebra1 200 000basic
Web Development1 150 000basic
Java Basics1 150 000basic
Excel for Business1 100 000basic
Graphic Design950 000basic
Data Visualization900 000basic
UX Design850 000basic
SQL for Analytics800 000basic

15 rows

Result 7

weekday_numn
0345
1327
2358
3405
4358
5331
6376

7 rows

Result 8

age_bandn
21 and under13
22 and over287

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?

  1. CAST truncates. Use ROUND if you want 4.

Summary

  • CASE WHEN is 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 CASE cannot match NULL; the searched form can.
  • CASE in ORDER BY gives 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 the ELSE 0 when the aggregate is AVG.
  • String work: ||, UPPER/LOWER, SUBSTR (1-based), TRIM, REPLACE, LENGTH, INSTR. Names differ a lot between databases.
  • TRIM + LOWER fixes most dirty text, but cleaning inside WHERE costs the index — clean the stored data instead.
  • Dates: strftime to extract, DATE(d, modifier) to shift, JULIANDAY to subtract. 'start of month','+1 month','-1 day' gives month-end safely.
  • Age = CAST(days / 365.25 AS INTEGER) — truncate, never round.
  • CAST to 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.

Write on Telegram Contents