SQL, CRUD Operations
Lesson 1 of 5
1SQL, CRUD Operations
The big pictureFrom creating your first table, to inserting, reading, updating, deleting and summarizing data with aggregate functions, on a single table, before relationships between tables enter the picture.
What the picture shows: INSERT adds the Tea row, SELECT lights up what costs under 10, UPDATE changes the Latte price, and DELETE removes Mocha.
Map of this part
The concepts in order, click any one to jump to it:
Two ways into the same idea
A database is like a very well-organised spreadsheet in an office. You can do only four things with it: add a row, read rows, change a row, delete a row. SQL is the language you write your request in for the spreadsheet's keeper: "give me the names of students scoring over 90, highest first".
CREATE TABLE defines columns, their types and constraints such as PRIMARY KEY and NOT NULL. Then INSERT, SELECT, UPDATE and DELETE (CRUD), with WHERE to filter, ORDER BY and LIMIT. Aggregates COUNT, SUM and AVG with GROUP BY summarise rows, and NULL means "unknown", tested with IS NULL.
Required Fundamentals
SQL is a new domain, but three things you already know make the jump easier:
The table: rows and columns
A table represents one kind of thing (drinks, students). Each row is one record and each column is an attribute with a declared type. If you have used Excel or Sheets the idea is familiar, the difference is that database columns have strict rules that protect them.
Dict and serialization (the Data structures and Serialization lessons)
A row resembles a single dict: fixed keys (column names) and values that change from row to row. Unlike Pickle/JSON, which rewrite the whole file, a database changes a single row and answers questions like “the priciest cold drink” without loading everything into memory.
Exceptions and files (the Exceptions and Files lessons)
A SQLite database is a single file on disk. Violating a constraint (a duplicate, empty or out-of-range value) returns an error; in Python it surfaces as a sqlite3.IntegrityError exception that you catch with try/except exactly as you learned.
Core Concepts
SQL is declarative: you state what you want from the data, not how to fetch it step by step. And crucially, it works on sets of rows at once, not row after row like a Python for loop.
1The four statements
INSERT, Create
Adds one or more rows: INSERT INTO t (columns) VALUES (…), (…). Columns you leave out get their DEFAULT or NULL. There is also INSERT … SELECT, which copies a query result into a table.
SELECT, Read
Reads without changing anything: choose columns (SELECT name, price_halalas), filter rows with WHERE, sort with ORDER BY, and cut with LIMIT.
UPDATE, Update
UPDATE t SET column = expr [, …] WHERE …. Every expression is evaluated against the row’s old values before any assignment, so SET a = b, b = a really swaps the two.
DELETE, Delete
DELETE FROM t WHERE … removes the rows whose condition is true. Rows where it is false or NULL stay, and the table itself stays (unlike DROP TABLE).
UPDATE or DELETE without WHERE. It is perfectly valid SQL, so it hits every row in the table with no warning or confirmation. The golden habit: write a SELECT with the same WHERE first, review the rows, then swap in the real statement.
2Conditions: choosing rows
| Operator | Meaning | Example |
|---|---|---|
= / <> / < / >= | Ordinary comparison | WHERE price_halalas >= 1800 |
AND / OR / NOT | Combine conditions, AND binds tighter than OR, so use parentheses | WHERE category = 'cold' AND (stock < 15 OR price_halalas > 2000) |
IN (…) / BETWEEN a AND b | Membership in a list / an inclusive range | WHERE name IN ('Cortado', 'Matcha') |
LIKE | Text pattern: % any run of characters, _ one character | WHERE name LIKE 'C%' |
IS NULL / IS NOT NULL | The only correct way to test for empty | WHERE note IS NULL |
3Ordering and limiting
Without ORDER BY the row order is not guaranteed, even if it looks stable today. ORDER BY price_halalas DESC, name sorts by price descending and alphabetically on ties. LIMIT 3 takes the first three rows after sorting; LIMIT 3 OFFSET 3 skips the first three. In SQLite, NULL sorts as smaller than any other value.
4Aggregate functions and GROUP BY
An aggregate function collapses many rows into one value. With GROUP BY you get one output row per group.
| Function | What it computes |
|---|---|
COUNT(*) | Number of rows, all of them |
COUNT(col) | Number of rows where col is not NULL |
SUM(col) / AVG(col) | Sum / average, both ignore NULL |
MIN(col) / MAX(col) | Smallest / largest value |
WHERE filters rows before grouping and HAVING filters groups after it. So you cannot write WHERE COUNT(*) > 2, SQLite rejects it with misuse of aggregate, you write HAVING COUNT(*) > 2.
SELECT category, name, COUNT(*) … GROUP BY category, which name should appear when the group holds several? Strict engines reject this query; SQLite allows it and returns a practically arbitrary value from the group. Never rely on that: every column in SELECT should either be in GROUP BY or inside an aggregate.
5Constraints, NULL and types
| Constraint | What it does | Example |
|---|---|---|
PRIMARY KEY | Identifies each row uniquely. An INTEGER PRIMARY KEY column fills itself in if you omit it. | id INTEGER PRIMARY KEY |
NOT NULL | Rejects an empty cell. | name TEXT NOT NULL |
UNIQUE | Rejects the same value in two rows. | name TEXT UNIQUE |
CHECK | Rejects any value that fails its expression. | CHECK (price_halalas > 0) |
DEFAULT | The value used when INSERT does not mention the column. | stock INTEGER DEFAULT 0 |
NULL means “no value / unknown”, not zero and not an empty string. Any comparison with it yields NULL, not “true”: NULL = NULL is not true, which is why we write IS NULL. Aggregates ignore NULL except COUNT(*).
In SQLite types are flexible: a column’s declared type is a preference (type affinity), not a hard rule, so text may be stored in an INTEGER column if it cannot be converted. Tables declared STRICT (since SQLite 3.37.0) enforce types rigidly, but do not assume your grading environment has it. Always write logically correct SQL and never lean on the engine’s leniency.
How Do These Statements Actually Work?
The safe workflow for changing and deleting
Diagram 21, SELECT is the “preview” of the dangerous statement: what it returns is exactly what will be affected.
The logical order a SELECT runs in
Diagram 22, we write SELECT first, but logically it runs near the end: that is why WHERE cannot see aggregate results.
What happens inside an UPDATE?
- The engine picks the rows where
WHEREis true only (false and NULL are skipped). - For each chosen row it evaluates every
SETexpression on the old values. - It checks the constraints (
NOT NULL,UNIQUE,CHECK…) on the new row. - On any violation the whole statement is cancelled (the default
ABORTbehavior) and nothing changes; otherwise the changes are kept.
A Complete Applied Example: a Café Drink Menu
We will build one table for a café’s drinks and take it through every operation. The domain is entirely different from your project’s data, so you practice applying the same approach yourself later.
Step 1, create the table with protective constraints
CREATE TABLE drinks (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
category TEXT NOT NULL CHECK (category IN ('hot', 'cold')),
price_halalas INTEGER NOT NULL CHECK (price_halalas > 0),
stock INTEGER NOT NULL DEFAULT 0,
note TEXT
);
The price is stored in halalas (an integer), not as a decimal riyal amount: binary floating-point can introduce rounding errors in money.
Step 2, insert: one row, many rows, and omitted columns
INSERT INTO drinks (name, category, price_halalas, stock)
VALUES ('Flat White', 'hot', 1600, 40);
INSERT INTO drinks (name, category, price_halalas, stock, note)
VALUES ('Cold Brew', 'cold', 1800, 25, '12h steeped'),
('Spanish Latte', 'cold', 1950, 18, NULL),
('Cortado', 'hot', 1400, 30, NULL),
('Matcha', 'cold', 2100, 12, 'ceremonial grade');
INSERT INTO drinks (name, category, price_halalas)
VALUES ('Americano', 'hot', 1200); -- stock omitted -> DEFAULT 0, note omitted -> NULL
Result: SELECT * FROM drinks ORDER BY id;
| id | name | category | price_halalas | stock | note |
|---|---|---|---|---|---|
| 1 | Flat White | hot | 1600 | 40 | NULL |
| 2 | Cold Brew | cold | 1800 | 25 | 12h steeped |
| 3 | Spanish Latte | cold | 1950 | 18 | NULL |
| 4 | Cortado | hot | 1400 | 30 | NULL |
| 5 | Matcha | cold | 2100 | 12 | ceremonial grade |
| 6 | Americano | hot | 1200 | 0 | NULL |
Notice Americano: we did not mention stock so it took 0 (the default), and we did not mention note so it became NULL. The id column filled itself in.
Step 3, read: condition, ordering and limit
SELECT name, price_halalas FROM drinks WHERE category = 'cold' AND stock >= 15 ORDER BY price_halalas DESC;
| name | price_halalas |
|---|---|
| Spanish Latte | 1950 |
| Cold Brew | 1800 |
SELECT name, price_halalas FROM drinks ORDER BY price_halalas DESC LIMIT 3;
| name | price_halalas |
|---|---|
| Matcha | 2100 |
| Spanish Latte | 1950 |
| Cold Brew | 1800 |
Matcha is excluded from the first query because its stock is only 12. The second query sorts first and cuts afterwards, change the sort and the three rows change.
Step 4, update: preview, then execute
SELECT id, name, price_halalas FROM drinks WHERE category = 'cold';
| id | name | price_halalas |
|---|---|---|
| 2 | Cold Brew | 1800 |
| 3 | Spanish Latte | 1950 |
| 5 | Matcha | 2100 |
UPDATE drinks SET price_halalas = price_halalas + 100 WHERE category = 'cold'; -- 3 rows changed
| id | name | price_halalas |
|---|---|---|
| 2 | Cold Brew | 1900 |
| 3 | Spanish Latte | 2050 |
| 5 | Matcha | 2200 |
price + 100 depends on the current state, whereas SET price_halalas = 1900 gives the same result however often you repeat it.
Step 5, delete: preview first
SELECT id, name, stock FROM drinks WHERE stock = 0;
| id | name | stock |
|---|---|---|
| 6 | Americano | 0 |
DELETE FROM drinks WHERE stock = 0; -- 1 row removed
In many real systems a row is not physically removed but flagged with a column like is_active = 0 (a soft delete) so it can be recovered or audited; a hard delete is irreversible.
Step 6, summarize: aggregates and GROUP BY
SELECT category,
COUNT(*) AS n,
AVG(price_halalas) AS avg_price,
MAX(price_halalas) AS max_price,
SUM(stock) AS total_stock
FROM drinks
GROUP BY category
ORDER BY category;
| category | n | avg_price | max_price | total_stock |
|---|---|---|---|---|
| cold | 3 | 2050.0 | 2200 | 55 |
| hot | 2 | 1500.0 | 1600 | 70 |
SELECT COUNT(*) AS all_rows, COUNT(note) AS with_note FROM drinks;
| all_rows | with_note |
|---|---|
| 5 | 2 |
COUNT(*) counted all five rows, while COUNT(note) counted only two because the other notes are NULL.
SELECT category, COUNT(*) AS n FROM drinks GROUP BY category HAVING COUNT(*) > 2;
| category | n |
|---|---|
| cold | 3 |
Step 7, what do the constraints say when violated?
| Attempt | SQLite error message |
|---|---|
INSERT … VALUES ('Mocha', 'warm', 1500) | CHECK constraint failed: category IN ('hot', 'cold') |
INSERT … VALUES ('Cortado', 'hot', 1500) | UNIQUE constraint failed: drinks.name |
INSERT … VALUES ('X', 'hot', 0) | CHECK constraint failed: price_halalas > 0 |
INSERT INTO drinks (name, category) VALUES ('X', 'hot') | NOT NULL constraint failed: drinks.price_halalas |
Step 8, from Python
import sqlite3
conn = sqlite3.connect("cafe.db")
try:
with conn: # commits on success, rolls back on any exception
cur = conn.execute(
"UPDATE drinks SET price_halalas = ? WHERE name = ?",
(1700, "Flat White"), # ? = value placeholder, never glue strings yourself
)
print("rows changed:", cur.rowcount) # 1
except sqlite3.IntegrityError as err:
print("rejected:", err) # e.g. CHECK constraint failed
finally:
conn.close()
? placeholder and pass the values separately as shown, so the engine treats them as data, not code.
Myth vs. Reality
| The myth | The reality |
|---|---|
DELETE FROM drinks deletes the table | It deletes every row only; the table’s structure stays. Removing the table itself takes DROP TABLE. |
WHERE note = NULL finds the empty rows | It never finds anything because comparing with NULL yields NULL, not “true”. The correct form is WHERE note IS NULL. |
| A SELECT without ORDER BY always returns rows in insertion order | The order is not guaranteed; it may change after an update or an index. Any result whose order matters needs an explicit ORDER BY. |
| SQLite rejects text inside an INTEGER column | Not necessarily: types are flexible, so if 'abc' cannot be converted to a number it is stored as text as is. (Only STRICT tables prevent this.) |
COUNT(note) and COUNT(*) are the same | COUNT(*) counts all rows, while COUNT(note) skips the rows where note is NULL. |
Where Are These Tools Actually Used?
Shopping carts
“Add to cart” = INSERT, “change quantity” = UPDATE, “remove item” = DELETE, and viewing the cart = SELECT, all CRUD on one table.
Dashboards
“Orders today”, “average price per category”, “highest-stocked products” are all COUNT/AVG/MAX with GROUP BY.
Data cleaning
Fixing bulk entry mistakes with a careful UPDATE … WHERE, after previewing the affected rows with SELECT.
Local mobile apps
SQLite is embedded in phones, browsers and many apps to keep local data with no server.
Try it yourself
Before DELETE FROM drinks WHERE price > 20, what is the safe step?
A SELECT with the same condition shows exactly the rows that will go. A delete outside an open transaction cannot be undone.
Quick Review Flashcards
UPDATE drinks SET stock = 0; with no WHERE?WHERE then HAVING.COUNT(*) and COUNT(col)?col is NULL.NULL = NULL evaluate to? What do we use instead?IS NULL.Check Your Understanding
1. (Recall) Name the four CRUD letters and the SQL statement for each.
Create → INSERT, Read → SELECT, Update → UPDATE, Delete → DELETE.
2. (Why) Why is UPDATE drinks SET price_halalas = price_halalas + 100 WHERE category = 'cold' not safe to repeat?
Because the expression depends on the current value, each new run adds another 100. Assigning a fixed value gives the same result however often you repeat it.
3. (Spot the error) What is wrong with UPDATE drinks SET price_halalas = 2000 AND stock = 5 WHERE id = 3;?
AND was used instead of a comma. SQLite evaluates 2000 AND stock = 5 as a boolean expression, so the price becomes 1 or 0, not 2000 (and 0 would be rejected by the CHECK). The correct form: SET price_halalas = 2000, stock = 5.
4. (Spot the error) Why does SELECT category, COUNT(*) FROM drinks WHERE COUNT(*) > 2 GROUP BY category; fail, and how do you fix it?
WHERE runs before grouping so it cannot see the COUNT result (a misuse of aggregate error). Fix: move the condition to HAVING COUNT(*) > 2 after GROUP BY.
5. (Predict) In our table after the delete, what does SELECT COUNT(note) FROM drinks; return?
2, only Cold Brew and Matcha have a note; the other rows are NULL and are not counted.
Resources for this part
Sorted by kind: start with the official docs when you want the precise reference, or with an article or video when you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs2
Done with this lesson?
Sign in to save your progress.