Database (SQLite)
Notes from Database Management: how SQLite stores, filters, and computes data. Each topic ends with a quick review.
1. Data Type
Big idea
In most databases the column owns the type and rejects bad values.
SQLite is different: a declared type is a preference, not a rule. A column declared INTEGER will happily hold the word hello.
If you don't know this, SQLite can give you a total that is quietly wrong.
The five storage classes
Every value is stored as exactly one of these:
Class |
Holds |
Example |
|---|---|---|
NULL |
Nothing. Not zero, not empty string; the absence of a value |
a phone number nobody gave |
INTEGER |
Whole number, + or - |
42, -7, 0 |
REAL |
Fractional number, 8-byte floating point |
3.14, -0.5, 2.0 |
TEXT |
String of characters |
'Kano', '2026-09-23', '42' |
BLOB |
Bytes, stored/returned exactly as given. SQLite never looks inside |
image, PDF, encrypted field |
Five. That's the whole list. No boolean, date, money, or fixed-length string type.
Missing types and what to do instead
- Boolean
No TRUE/FALSE type. Store 0 = false, 1 = true as INTEGER. Newer SQLite accepts the words TRUE/FALSE in SQL and turns them into 1/0.
- Date and time
No date type. Store as TEXT like '2026-09-23 14:30:00' (or as a number). The text form sorts correctly with a plain ORDER BY because the biggest unit comes first. This gets its own topic later.
- Money
No money type. REAL is a trap: floating point can't hold 0.10 exactly. Store whole cents as INTEGER (1050, not 10.50) and divide when displaying.
Type affinity (the surprising part)
The declared column type is the column's affinity: what it would like to hold. SQLite tries to convert values to it but doesn't enforce it.
CREATE TABLE student (
id INTEGER,
name TEXT,
mark REAL
);
INSERT INTO student VALUES (1, 'Amina', 72.5);
INSERT INTO student VALUES ('two', 'Bello', 'absent');
Both inserts succeed, with no error or warning. 'two' sits in an INTEGER column and 'absent' in a REAL one.
How a declared type becomes one of five affinities
SQLite looks for text inside the type name you wrote. Rules are checked in this order, and the first match wins:
# |
Affinity |
Declared type contains... |
Examples |
|---|---|---|---|
1 |
INTEGER |
INT |
INT, INTEGER, BIGINT, TINYINT, INT8 |
2 |
TEXT |
CHAR, CLOB, or TEXT |
VARCHAR(50), CHARACTER(20), NVARCHAR, TEXT |
3 |
BLOB |
BLOB, or no type at all |
BLOB, a column with no type |
4 |
REAL |
REAL, FLOA, or DOUB |
REAL, FLOAT, DOUBLE, DOUBLE PRECISION |
5 |
NUMERIC |
anything else |
DECIMAL(10,5), BOOLEAN, DATE, DATETIME, STRING |
Things to remember:
DATE and BOOLEAN get NUMERIC affinity. They get no special handling; they just fail rules 1–4.
VARCHAR(20) contains CHAR, so it gets TEXT.
Order matters. A type name that matches two rules gets the earlier one. For example, CHARINT contains INT, so rule 1 makes it INTEGER.
STRING gets NUMERIC, not TEXT, because it doesn't contain CHAR, CLOB or TEXT.
What affinity actually does
Affinity never rejects. It converts when it can and quietly gives up when it can't.
Column affinity |
You insert |
What's stored |
|---|---|---|
INTEGER |
'42' (text) |
42 as INTEGER (looks like a whole number) |
INTEGER |
'hello' |
'hello' as TEXT (can't convert) |
TEXT |
42 (number) |
'42' as TEXT |
REAL |
7 (integer) |
7.0 as REAL |
BLOB |
anything |
exactly as given; never converts |
So two rows in the same column can have different storage classes. That's legal, and it's the root of the bug below.
Why this bites: the wrong total
The PDF's example:
CREATE TABLE sale (item TEXT, amount INTEGER);
INSERT INTO sale VALUES ('bread', '120');
INSERT INTO sale VALUES ('milk', '85');
INSERT INTO sale VALUES ('rice', ' 200'); -- note the leading space
INSERT INTO sale VALUES ('sugar', '64');
SELECT SUM(amount) FROM sale;
PDF's claim: the leading space stops ' 200' converting, so it's stored as TEXT. SUM treats text as 0, giving 269 instead of 469. No error and no warning, just a believable wrong total.
Finding what's really there: typeof()
The declared type doesn't tell you, so ask each value:
SELECT item, amount, typeof(amount) FROM sale;
bread 120 integer
milk 85 integer
rice 200 text <-- there it is (per the PDF)
sugar 64 integer
typeof() is THE tool for this topic. When a number won't add up, a comparison returns nothing, or a sort looks strange, check the real types first.
Making the type a rule again
To reject bad values instead of converting them, add a constraint (covered in its own topic later):
CREATE TABLE sale (
item TEXT NOT NULL,
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer')
);
Bad rows are now rejected at insert time: "you find out in September instead of December."
The one exception: INTEGER PRIMARY KEY
CREATE TABLE student (
id INTEGER PRIMARY KEY,
name TEXT
);
A column declared exactly INTEGER PRIMARY KEY:
becomes an alias for SQLite's internal row id (rowid),
must hold an integer, so this is the one place the type is enforced.
INT PRIMARY KEY does not get this behaviour. It's an ordinary column, because the exception requires the exact word INTEGER.
Quick review
Name the five storage classes. NULL, INTEGER, REAL, TEXT, BLOB.
How do you store a boolean? INTEGER 0/1.
A date? TEXT like '2026-09-23 14:30:00' (sorts correctly).
Money? Whole cents as INTEGER, because REAL can't hold 0.10 exactly.
Does INSERT ... ('two', ...) into an INTEGER column fail? No. It's stored as TEXT.
Affinity of VARCHAR(50)? TEXT. BOOLEAN? NUMERIC. DATE? NUMERIC. FLOAT? REAL. BIGINT? INTEGER. No type at all? BLOB. STRING? NUMERIC.
'42' into an INTEGER column? Stored as integer 42. 42 into a TEXT column? Stored as text '42'.
What does BLOB affinity convert? Nothing.
Function to see a value's real type? typeof().
How do you force a column to reject non-integers? CHECK (typeof(col) = 'integer').
INTEGER PRIMARY KEY vs INT PRIMARY KEY? Only the exact word INTEGER makes it a rowid alias with an enforced type.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T01.pdf, and upload it to hand_in.
2. ATTACH and DETACH Database
Big idea
A SQLite database is one file. Copy the file = copy the database.
One connection can open more than one file. ATTACH adds a database to the connection; DETACH removes it.
Result: you can query across several database files in one statement.
Databases you already have
Every connection starts with two:
- main
The database you opened. SELECT * FROM student really means SELECT * FROM main.student.
- temp
Scratch database for this connection only, thrown away on disconnect. CREATE TEMP TABLE puts tables here.
Key point: the database.table prefix is the whole mechanism. ATTACH just adds more names you can put before the dot.
Attaching
ATTACH DATABASE 'archive.db' AS archive;
- 'archive.db'
The filename, in quotes. Relative to where you started sqlite3, or an absolute path. If the file doesn't exist, SQLite creates it empty without complaining.
- AS archive
The alias used in queries. Doesn't have to match the filename.
Then:
SELECT * FROM archive.sale;
SELECT * FROM main.sale;
Checking what's attached
.databases
A sqlite3 shell command, not SQL: leading dot, no semicolon.
Lists every attached database and its file, which is how you catch the typo.
Why use it (three uses)
Moving rows between files. Use an ordinary INSERT ... SELECT, with no export, CSV or script needed:
ATTACH DATABASE 'last_year.db' AS old; INSERT INTO main.sale SELECT * FROM old.sale WHERE date >= '2025-01-01';Comparing two versions of the same data:
ATTACH DATABASE 'backup.db' AS backup; SELECT id FROM main.student EXCEPT SELECT id FROM backup.student; -- students added since the backupSwap the two sides to find the students who were deleted.
Splitting a database that has grown. Archive old rows to a second file, delete them from the first, and attach the archive when someone needs the history.
Rules and limits
Not inside a transaction |
ATTACH/DETACH must run outside BEGIN ... COMMIT |
Max attached |
10 by default (SQLITE_MAX_ATTACHED); raising it means recompiling SQLite |
Alias must be unique |
Two databases can't both be archive. main and temp are reserved |
A transaction spans all of them |
BEGIN covers every attached DB; a failed commit rolls them all back together, which is why cross-DB copying is safe |
Detaching
DETACH DATABASE archive;
No quotes. You name the alias, not the file.
Detaching does NOT: delete the file, empty it, or undo anything written to it.
Detaching DOES: free the alias for reuse, release the file lock, and make archive.anything an error again.
Closing the connection detaches everything automatically. DETACH matters when you need the name or file released while you keep working.
Quick review
What does SELECT * FROM student actually read? main.student
What happens if you ATTACH a misspelled filename? A new empty DB is created; there is no error until a query fails with "no such table".
How do you see what's attached? The .databases shell command.
Quotes in ATTACH vs DETACH? ATTACH quotes the filename; DETACH takes the unquoted alias.
Default attach limit? 10.
Can you ATTACH inside BEGIN ... COMMIT? No.
Which two names are reserved? main and temp.
Does DETACH delete data? No. It only drops the connection to the file.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T02.pdf, and upload it to hand_in.
3. DROP Table
Big idea
DROP TABLE removes a table completely: definition, columns, every row. The space is marked free.
No undo and no confirmation. Inside a transaction you can still ROLLBACK; outside one, only a backup file can save you.
The topic is mainly about what else goes with it, and DROP vs DELETE (emptying vs removing).
The statement
DROP TABLE student;
IF EXISTS
Dropping a missing table is an error:
Error: no such table: student
That error is usually useful because it shows you were wrong about the database state. In setup scripts that must run either way, use:
DROP TABLE IF EXISTS student;
Now a missing table is a no-op, not an error. This is common at the top of setup scripts: "make sure this table doesn't exist before I create it."
What else disappears (memorise this)
Indexes on the table |
Dropped automatically. No need to drop first |
Triggers on the table |
Dropped automatically |
Views referencing it |
NOT dropped. The view remains but fails with no such table when used |
Why views survive: a view is a stored query, not stored data, so there's nothing to clean up. SQLite leaves a query that can no longer run.
Foreign keys pointing at the table
PRAGMA foreign_keys = ON;
OFF (SQLite's default): the drop succeeds, and the other table keeps references to a table that no longer exists.
ON: the drop fails if any row would be left pointing at nothing.
Foreign key enforcement must be turned on per connection, every time (same as the pet program).
DROP vs DELETE (the key distinction)
DROP TABLE student; |
DELETE FROM student; |
|
|---|---|---|
Removes |
The table and its rows |
The rows only |
SELECT * after |
Error |
Returns nothing (empty) |
Definition |
Gone (columns, types, constraints) |
Untouched; insert right away |
Indexes & triggers |
Gone with it |
Still there |
Can filter? |
No, all or nothing |
Yes: WHERE mark < 40 |
"Clear the table" almost always means DELETE, not DROP. Dropping and recreating only works if you have the exact CREATE TABLE, and any index, trigger or constraint you forget to recreate is silently missing.
TRUNCATE?
Other databases have TRUNCATE TABLE. SQLite does not have it at all.
Instead, SQLite has the truncate optimization: DELETE FROM student with no WHERE is recognised and the content is removed all at once instead of row by row. It's fast without any special syntax.
This is syllabus objective 36 and will get its own topic.
Quick review
Can you undo DROP TABLE? Only with ROLLBACK inside a transaction; otherwise you need a backup.
What does IF EXISTS change? A missing table becomes a no-op instead of an error.
Danger of IF EXISTS? It hides typos in the table name.
Indexes / triggers / views after a drop? Indexes and triggers are dropped; views are NOT dropped and break.
Why aren't views dropped? A view is a stored query, not stored data.
Default foreign key enforcement in SQLite? OFF, and it's per connection.
Clear all rows but keep the table? DELETE FROM student;
Does SQLite have TRUNCATE? No. DELETE without WHERE is optimized instead.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T03.pdf, and upload it to hand_in.
4. Operators
Big idea
An operator combines values into a new one: +, =, AND.
The two rules that bite (both fail silently with believable answers):
Integer ÷ integer throws away the remainder.
Comparing anything to NULL is never true, not even NULL = NULL.
Arithmetic
+ |
add |
7 + 2 → 9 |
- |
subtract |
7 - 2 → 5 |
* |
multiply |
7 * 2 → 14 |
/ |
divide |
7 / 2 → 3 |
% |
remainder |
7 % 2 → 1 |
Fix: make one side real
SELECT 7 / 2; -- 3 both integers
SELECT 7.0 / 2; -- 3.5 one is real
SELECT 7 / 2.0; -- 3.5 either side works
SELECT 7 * 1.0 / 2; -- 3.5 the usual trick on columns
With columns, where you can't type a decimal point:
SELECT total * 1.0 / count AS average FROM summary;
Division by zero is not an error either. It returns NULL, which then spreads through everything it touches.
Comparison
= or == |
equal (= is usual) |
!= or <> |
not equal |
< > <= >= |
less/greater (or equal) |
BETWEEN a AND b |
inclusive at both ends: BETWEEN 1 AND 10 includes 1 and 10 |
IN (a, b, c) |
equal to any value in the list |
LIKE / GLOB |
pattern matching (own topics later) |
NULL: the rule that catches everyone
NULL means unknown. It does not mean zero, empty, or blank.
SELECT 5 = NULL; -- NULL, not 0
SELECT NULL = NULL; -- NULL, not 1
SELECT 5 + NULL; -- NULL
SELECT 'abc' || NULL; -- NULL
Is 5 equal to an unknown number? We don't know, so the answer is NULL.
WHERE keeps only rows where the condition is true. NULL isn't true, so comparing with NULL keeps nothing:
SELECT * FROM student WHERE phone = NULL; -- always returns nothing
SELECT * FROM student WHERE phone IS NULL; -- correct
The one that's worse
SELECT * FROM student WHERE mark != 50;
Students with a NULL mark are NOT returned, because "is unknown ≠ 50?" is unknown. Those rows vanish from both = 50 and != 50, even though the two together look like they cover everyone. To include them:
SELECT * FROM student WHERE mark != 50 OR mark IS NULL;
Logical operators
AND |
true only when both sides are true |
OR |
true when either side is true |
NOT |
flips true/false, but NULL stays NULL |
Precedence: AND binds tighter than OR (like * before +).
WHERE course = 'CSCI 3610' AND mark > 70 OR mark > 90
means (3610 students over 70) OR (anyone at all over 90), which is probably not what was intended.
Joining text
|| concatenates. There is no + for strings in SQLite.
SELECT first_name || ' ' || last_name AS full_name FROM student;
It follows the NULL rule: if last_name is NULL, the whole full_name is NULL. The fix is COALESCE (see Expressions).
Quick review
SELECT 7 / 2;? 3 (integer division).
Two ways to get 3.5? 7.0 / 2 or 7 * 1.0 / 2.
SELECT 5 / 0;? NULL, not an error.
Does BETWEEN 1 AND 10 include 1 and 10? Yes, it's inclusive.
NULL = NULL? NULL.
Correct way to find missing phones? WHERE phone IS NULL.
Why does mark != 50 miss rows? NULL marks give unknown, not true.
Which binds tighter, AND or OR? AND.
String concatenation operator? ||.
'abc' || NULL? NULL.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T04.pdf, and upload it to hand_in.
5. Expressions
Big idea
An expression is anything that produces a value: 42, price * 1.2, UPPER(name), or a whole SELECT in brackets.
Operators (topic 4) join expressions together. This topic covers what they join, plus the two decision/type tools: CASE and CAST.
Where an expression can appear
Anywhere a value is expected, an expression will do.
SELECT price * 1.2 -- in the column list
FROM product
WHERE price * 1.2 > 100 -- in the filter
ORDER BY price * 1.2 DESC; -- in the sort
Literals
Kind |
Written |
Note |
|---|---|---|
Number |
42, -3.5 |
No quotes, ever |
Text |
'Kano' |
Single quotes |
Quote inside text |
'it''s' |
Double the apostrophe. No backslash escape |
NULL |
NULL |
No quotes. 'NULL' is just a four-letter word |
CASE: making a decision in a query
CASE is SQL's if. It's the most useful thing in this topic.
SELECT name,
mark,
CASE
WHEN mark >= 70 THEN 'Distinction'
WHEN mark >= 50 THEN 'Pass'
ELSE 'Fail'
END AS grade
FROM student;
Rules
Order matters |
The first true WHEN wins. Put the narrowest condition first |
ELSE is optional |
If it's left out and nothing matches, the result is NULL, not an error or empty string |
END is required |
Forgetting it is the usual syntax error |
It's an expression |
It can go in WHERE and ORDER BY too |
Wrong order means wrong results with no error:
CASE
WHEN mark >= 50 THEN 'Pass' -- 85 matches HERE
WHEN mark >= 70 THEN 'Distinction' -- never reached
ELSE 'Fail'
END
Every distinction becomes a pass. The only way to catch it is to check a value you know should be a distinction.
CAST: changing a type on purpose
SQLite converts types on its own (topic 1). CAST asks for a conversion explicitly.
SELECT CAST('42' AS INTEGER); -- 42
SELECT CAST(9 AS REAL); -- 9.0
SELECT CAST(3.7 AS INTEGER); -- 3 truncated, not rounded
SELECT CAST('abc' AS INTEGER); -- 0 no error
Everyday use is forcing real division (from topic 4):
SELECT CAST(total AS REAL) / count FROM summary;
Handling NULL inside an expression
COALESCE(a, b, c) |
Returns the first argument that isn't NULL. Any number of args. You'll use this constantly. |
IFNULL(a, b) |
Two-argument version. COALESCE does everything it does |
NULLIF(a, b) |
Returns NULL if a = b, otherwise returns a |
COALESCE fixes the concatenation trap:
SELECT first_name || ' ' || COALESCE(last_name, '') AS full_name
FROM student;
NULLIF prevents division by zero (the 0 becomes NULL, so the result is NULL):
SELECT total / NULLIF(count, 0) FROM summary;
A subquery is an expression too
A bracketed SELECT that returns one value can go anywhere a value can:
SELECT name, mark,
mark - (SELECT AVG(mark) FROM student) AS above_average
FROM student;
Subqueries get their own topic later.
Quick review
What is an expression? Anything that produces a value.
Where can expressions go? Anywhere a value is expected: SELECT list, WHERE, ORDER BY, and more.
How do you write it's as a literal? 'it''s'.
'name' vs "name"? Text value vs column name.
CASE with no ELSE and no match? NULL.
Which WHEN wins? The first true one.
CAST(3.7 AS INTEGER)? 3 (truncated).
CAST('abc' AS INTEGER)? 0.
COALESCE(NULL, NULL, 'x', 'y')? 'x'.
NULLIF(5, 5)? NULLIF(5, 3)? NULL; 5.
Safe division when count may be 0? total / NULLIF(count, 0).
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T05.pdf, and upload it to hand_in.
6. WHERE Clause
Big idea
WHERE decides which rows you get. The rest of the SELECT decides what you see about them.
SQLite takes each row in turn, tests the condition against that one row, and keeps the row only if the answer is true.
Row by row, on its own
SELECT name, mark FROM student WHERE mark > 50;
For each row SQLite asks is this row's mark greater than 50? and keeps or drops it. The rows know nothing about each other.
That's why this fails:
SELECT name FROM student WHERE mark > AVG(mark); -- error
While one row is being tested, there is no average. The average is a fact about the whole table, and a single row can't see the whole table. Use a subquery (later topic):
SELECT name FROM student WHERE mark > (SELECT AVG(mark) FROM student);
True, false, and the third answer
A condition has three possible results, and only true keeps the row:
Result |
Row is |
When |
|---|---|---|
true |
kept |
the condition holds |
false |
dropped |
the condition doesn't hold |
NULL |
dropped |
it couldn't be decided, usually a NULL value |
This is the NULL problem from Operators, showing up where it matters:
SELECT COUNT(*) FROM student WHERE mark > 50; -- 22
SELECT COUNT(*) FROM student WHERE mark <= 50; -- 12
-- 22 + 12 = 34, but the table has 40 rows. Six marks are NULL.
What you can put in it
Everything from Operators and Expressions:
WHERE mark > 50
WHERE mark BETWEEN 50 AND 70 -- inclusive at both ends
WHERE course IN ('CSCI 3610', 'CSCI 2020')
WHERE mark IS NULL
WHERE mark IS NOT NULL
WHERE name LIKE 'A%' -- topic 8
WHERE mark * 1.1 > 60 -- an expression
WHERE CASE WHEN retake THEN mark + 5 ELSE mark END > 50
WHERE takes an expression, not just a column. Anything that produces a value can be tested.
Conditions on a computed column
SELECT mark * 1.1 AS adjusted FROM student WHERE adjusted > 60;
The course says this is an error: WHERE runs before the column list is built, so the alias adjusted doesn't exist yet. Repeat the expression:
SELECT mark * 1.1 AS adjusted FROM student WHERE mark * 1.1 > 60;
HAVING (with GROUP BY, later) can see aliases. That's one of the real differences between the two.
NOT, and where it goes
WHERE NOT (mark > 50)
WHERE mark NOT BETWEEN 50 AND 70
WHERE course NOT IN ('CSCI 3610')
WHERE name NOT LIKE 'A%'
NOT does not rescue NULL rows. NOT (NULL) is still NULL, so the row is still dropped. If you want the unknowns, ask for them:
WHERE NOT (mark > 50) OR mark IS NULL
Two habits worth forming
Count before you filter. Run SELECT COUNT(*) FROM student; first. Then if a filter returns 34 of 40, you notice the missing six.
Write the condition in words first. "Students in 3610 who scored over 70, and anyone at all who scored over 90" tells you where the brackets go. The SQL is only as right as the sentence.
Quick review
How does WHERE evaluate a condition? One row at a time, on its own.
Why does WHERE mark > AVG(mark) fail? A single row can't see the whole table's average. Use a subquery.
The three results of a condition? True (kept), false, NULL (both dropped).
Why might > 50 and <= 50 not add up to the row count? NULL marks are dropped by both.
Can WHERE test an expression? Yes, anything that produces a value.
Can WHERE use a SELECT alias? Not in standard SQL; it runs before the column list. (SQLite allows it, but don't rely on it.)
Does NOT bring back NULL rows? No. Add OR col IS NULL.
Two habits? Count before filtering; write it in words first.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T06.pdf, and upload it to hand_in.
7. AND and OR Operators
Big idea
One condition is easy. Faults start when you join two, and they're not syntax errors: everything here runs fine and returns the wrong rows.
Three causes of almost all of them:
AND binds tighter than OR.
NOT doesn't distribute the way English does.
A range needs two conditions joined by AND, not one.
Precedence: the rule and the habit
AND binds tighter than OR, like * before +.
WHERE course = 'CSCI 3610' AND mark > 70 OR mark > 90
is read by SQLite as
WHERE (course = 'CSCI 3610' AND mark > 70) OR (mark > 90)
3610 students over 70, or anybody at all over 90. A physics student with 95 is in the result.
If you meant 3610 students over 70 or over 90:
WHERE course = 'CSCI 3610' AND (mark > 70 OR mark > 90)
The range mistake
The most common wrong WHERE in student SQL:
WHERE 50 < mark < 70 -- runs, and is nonsense
No error. SQLite works left to right: 50 < mark gives 0 or 1, then compares that to 70. 0 < 70 and 1 < 70 are both true, so every row passes, including marks of 5 and 200.
Correct forms:
WHERE mark > 50 AND mark < 70
WHERE mark BETWEEN 51 AND 69 -- BETWEEN is inclusive, so bounds shift
BETWEEN is not a drop-in replacement for two strict comparisons. (And 51 AND 69 only matches the strict version for whole-number marks: a mark of 50.5 passes the first and fails the second.)
One column, two values
Reads perfectly in English, always wrong:
WHERE course = 'CSCI 3610' AND course = 'CSCI 2020'
"Students in 3610 and 2020." Always returns nothing. One row has one course, and one value can't equal two different strings.
WHERE course = 'CSCI 3610' OR course = 'CSCI 2020'
WHERE course IN ('CSCI 3610', 'CSCI 2020') -- same, tidier
AND is right when the conditions are about different columns: in 3610 and scoring over 70.
NOT, and what it does to a pair
NOT flips true/false and leaves NULL as NULL.
Negating a compound condition flips the operator as well as the comparisons. This is De Morgan's law:
NOT (a AND b) |
same as NOT a OR NOT b |
NOT (a OR b) |
same as NOT a AND NOT b |
The AND becomes an OR. "Not (in 3610 and over 70)" = "not in 3610, or not over 70". That catches more than people expect: a 3610 student who scored 60 satisfies it.
WHERE NOT (course = 'CSCI 3610' AND mark > 70)
WHERE course != 'CSCI 3610' OR mark <= 70
NULL through a compound condition
Expression |
Result |
Read it aloud |
|---|---|---|
true AND NULL |
NULL |
one true, one unknown → unknown |
false AND NULL |
false |
one is already false → false regardless |
true OR NULL |
true |
one is already true → true regardless |
false OR NULL |
NULL |
one false, one unknown → unknown |
AND can be decided by a false; OR can be decided by a true, without knowing the other side.
And from WHERE: a row whose condition is NULL is dropped, so compound conditions on NULL columns quietly lose rows.
WHERE mark > 50 AND course = 'CSCI 3610'
A 3610 student with no recorded mark isn't returned, and nothing says so.
Write it in words first
Every fault here is a translation error. Translate deliberately:
Sentence |
Shape |
|---|---|
"3610 students scoring over 70" |
two columns → AND |
"students in 3610 or 2020" |
one column, two values → OR or IN |
"marks between 50 and 70" |
a range → two conditions with AND, or BETWEEN |
"everyone except 3610 students over 70" |
negated pair → De Morgan, AND becomes OR |
Say the sentence, decide its shape, then write it.
Quick review
Which binds tighter, AND or OR? AND.
How does SQLite read a AND b OR c? (a AND b) OR c.
What does WHERE 50 < mark < 70 return? Every row.
Two correct ways to write that range? mark > 50 AND mark < 70; mark BETWEEN 51 AND 69 (whole numbers).
course = 'A' AND course = 'B'? Always empty. Use OR / IN.
NOT (a AND b) equals? NOT a OR NOT b.
false AND NULL? false. true OR NULL? true.
true AND NULL? false OR NULL? Both NULL.
Habit that prevents all of these? Write it in words first, and bracket mixed AND/OR.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T07.pdf, and upload it to hand_in.
8. LIKE and GLOB Clauses
Big idea
= matches a whole value exactly. LIKE and GLOB match a pattern: starts with A, ends in .pdf, contains kano.
Same job, different wildcards and different rules about capital letters. Choosing between them is most of the skill.
LIKE
% |
any run of characters, incl. none |
'A%' matches Amina, Ahmed, and A |
_ |
exactly one character |
'_mina' matches Amina, not mina |
WHERE name LIKE 'A%' -- begins with A
WHERE name LIKE '%a' -- ends with a
WHERE name LIKE '%kano%' -- contains kano anywhere
WHERE name LIKE 'A%a' -- begins with A and ends with a
WHERE code LIKE '___' -- exactly three characters
LIKE is case-insensitive for ASCII letters. 'a%' and 'A%' return the same rows.
GLOB
Command-line wildcards, and case-sensitive, always.
* |
any run of characters, incl. none |
'A*' matches Amina, not amina |
? |
exactly one character |
'?mina' matches Amina |
[...] |
any one character from the set |
'[AB]*': starts with A or B |
[a-z] |
a range |
'[0-9]*': starts with a digit |
[^...] |
any one character not in set |
'[^0-9]*': doesn't start with a digit |
WHERE name GLOB 'A*' -- capital A only
WHERE file GLOB '*.pdf' -- ends in .pdf, lower case only
WHERE code GLOB '[A-Z][A-Z][0-9]' -- two capitals then a digit
Character classes are the reason to reach for GLOB. LIKE can't say any one of these characters.
Choosing between them
LIKE |
GLOB |
|
|---|---|---|
Any run |
% |
* |
One character |
_ |
? |
Character sets |
not available |
[abc] [a-z] [^0-9] |
Capital letters |
ignored (ASCII) |
respected, always |
Standard SQL |
yes, portable |
SQLite-specific |
Matching a literal % or _
Use ESCAPE to say this one isn't a wildcard:
WHERE note LIKE '%50\%%' ESCAPE '\'
Read in pieces: % any run · 50 · \% a real percent sign · % any run. ESCAPE '\' makes the backslash mean next character is ordinary. Any character can be the escape; backslash is conventional.
GLOB has no ESCAPE. Put the character in a class instead: '*[*]*' matches anything containing a literal asterisk.
Why a leading wildcard is slow
WHERE name LIKE 'A%' -- can use an index on name
WHERE name LIKE '%a' -- cannot; every row must be examined
An index is sorted, so starts with A is a range it can jump to. Ends with a isn't a range, so it's a full scan.
Invisible on 40 rows; on 4 million it's instant vs. a minute. Avoid a leading wildcard when you can, and know you've chosen a full scan when you can't. (Indexes get their own topic later.)
Quick review
LIKE wildcards? % any run (incl. none), _ exactly one.
GLOB wildcards? *, ?, [...], [^...].
Is LIKE case-sensitive? No, for ASCII letters. Accented letters are.
Is GLOB case-sensitive? Yes, always.
Does '_mina' match mina? No, _ needs exactly one character.
What can GLOB do that LIKE can't? Character sets/ranges.
How do you match a literal % with LIKE? ESCAPE, e.g. LIKE '%50\%%' ESCAPE '\'.
Literal * with GLOB? Put it in a class: '*[*]*'.
Why is LIKE '%a' slow? No index range to jump to: full scan.
Which one for searching typed names? LIKE. For codes? GLOB.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T08.pdf, and upload it to hand_in.
9. LIMIT and ORDER BY Clauses
Big idea
There is no natural order. Not insertion order, not primary-key order, not the order you saw last time. Want an order? Ask for one.
ORDER BY sorts. LIMIT takes the first few.
LIMIT without ORDER BY is meaningless. That's the fault in this topic.
ORDER BY
SELECT name, mark FROM student ORDER BY mark; -- ascending (default)
SELECT name, mark FROM student ORDER BY mark DESC; -- descending
SELECT name, mark FROM student ORDER BY course, mark DESC;
The third sorts by course, then within each course by mark descending. Read multi-column sorts as group by the first, then arrange inside it.
ASC |
ascending; the default, usually left out |
DESC |
descending. Applies to one column only: ORDER BY a, b DESC sorts a ascending, b descending |
By position |
ORDER BY 2 = 2nd result column. Short, unreadable later |
By alias |
ORDER BY adjusted works. Unlike WHERE, ORDER BY runs after the column list is built |
Where NULL goes
SQLite sorts NULL first ascending, last descending. It treats unknown as smaller than everything.
SELECT name FROM student ORDER BY mark NULLS LAST;
Other databases differ, so write NULLS FIRST / NULLS LAST explicitly when it matters.
Text sorts differently from numbers
'10' sorts before '9': text compares character by character, and '1' comes before '9'. Numbers stored as text sort in an order that looks random.
LIMIT
SELECT name, mark FROM student ORDER BY mark DESC LIMIT 5; -- top five
SELECT name, mark FROM student ORDER BY mark DESC LIMIT 5 OFFSET 10; -- 11th to 15th
OFFSET skips rows before taking them. That's paging: page 3 at ten per page is LIMIT 10 OFFSET 20. (Offset = (page − 1) × page size.)
Every LIMIT needs an ORDER BY. If you can't say what order you want, you don't yet know what question you're asking.
Ties are not resolved
ORDER BY mark DESC LIMIT 3 with four students on 90 returns three of the four, chosen arbitrarily. Add a tiebreaker:
ORDER BY mark DESC, name ASC LIMIT 3
Quick review
Default row order with no ORDER BY? None. It's undefined.
Default direction of ORDER BY? ASC.
ORDER BY a, b DESC: what's descending? Only b.
Can ORDER BY use an alias? Yes; it runs after the column list.
Where does SQLite put NULL ascending? First. Descending? Last.
Why does '10' sort before '9'? Text compares character by character. Check typeof().
Rows 11–15? LIMIT 5 OFFSET 10.
Page 3 at 10 per page? LIMIT 10 OFFSET 20.
What's wrong with LIMIT alone? Arbitrary rows; always add ORDER BY.
How do you make ties deterministic? Add a tiebreaker column.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T09.pdf, and upload it to hand_in.
10. Functions
Big idea
A function takes values and returns one. There are two kinds, and telling them apart is the whole topic. The difference explains GROUP BY (topic 11).
Kind |
Shape |
Examples |
|---|---|---|
Scalar |
one row in, one row out |
UPPER(name), LENGTH(name), ROUND(mark, 1) |
Aggregate |
many rows in, one row out |
COUNT(*), SUM(mark), AVG(mark) |
Scalar functions
Work row by row, like any expression, and go anywhere an expression can.
LENGTH(x) |
number of characters |
UPPER(x) LOWER(x) |
case conversion. ASCII only (same limit as LIKE) |
TRIM LTRIM |
remove spaces: both ends / left / right |
RTRIM |
|
SUBSTR(x, start, len) |
part of a string. Counts from 1, not 0 |
REPLACE(x, find, put) |
substitute text |
ROUND(x, n) |
round to n places. Really rounds (unlike CAST, which truncates) |
ABS(x) |
absolute value |
COALESCE IFNULL |
from Expressions |
NULLIF |
|
typeof(x) |
from Data Types |
SELECT SUBSTR('Kano', 1, 2); -- 'Ka' (starts at 1)
SELECT ROUND(3.75, 1); -- 3.8
SELECT CAST(3.75 AS INTEGER); -- 3 (truncates)
Aggregate functions
COUNT(*) |
counts rows, including rows that are all NULL |
COUNT(column) |
counts non-NULL values in that column |
SUM(x) AVG(x) |
total and mean. Both ignore NULL entirely |
MIN(x) MAX(x) |
smallest and largest |
GROUP_CONCAT(x, sep) |
joins the values into one string |
The average that isn't the average
AVG ignores NULL, so it divides by the number of recorded marks, not the number of students.
SELECT AVG(mark) FROM student; -- 64.2 (sum / 34)
SELECT SUM(mark) * 1.0 / COUNT(*) FROM student; -- 54.6 (sum / 40)
Both are defensible; they're different numbers. Which one you want depends on whether a missing mark means we don't know or they scored nothing. That's a question about the school, not SQL.
(Note the * 1.0: without it, integer ÷ integer would truncate. See Operators.)
An aggregate collapses the rows
SELECT name, AVG(mark) FROM student; -- one row, and name is arbitrary
AVG returns one row, so which name? SQLite picks one arbitrarily and doesn't complain. Most databases raise an error here.
That's why GROUP BY exists: the next topic.
Quick review
Scalar vs aggregate? One row in/one out vs many rows in/one out.
SUBSTR('Kano', 1, 2)? 'Ka'. Counting starts at 1.
ROUND(3.75, 1) vs CAST(3.75 AS INTEGER)? 3.8 vs 3.
Are UPPER/LOWER Unicode-aware? No, ASCII only.
COUNT(*) vs COUNT(mark)? All rows vs non-NULL marks.
Do SUM and AVG count NULLs? No, they ignore them.
Average treating missing marks as 0? SUM(mark) * 1.0 / COUNT(*).
What does SELECT name, AVG(mark) FROM student give in SQLite? One row with an arbitrary name (most DBs error).
Which function fixes leading spaces? TRIM.
Quiz logistics
Handwritten (blank or dark blue pen, one side of white paper). Scan with Google Drive, name it T10.pdf, and upload it to hand_in.