Web Foundations & Databases · beginner · ~12 min
By the end of this lesson you will be able to: - Describe what a **relational database** is and how data is organized into **tables, rows, and typed columns**. - Read and write the four core SQL statements: **SELECT** (read), **INSERT** (add), **UPDATE** (change), and **DELETE** (remove). - Filter rows correctly with a **WHERE** clause and explain why WHERE is the classic SQL-injection target. - Combine data from two tables with **JOIN** using a shared key. - Stack two query results with **UNION** and recognize the **column-count / column-type** rules it requires. - Trace, by hand, what an attacker-influenced WHERE or UNION turns a query into — the groundwork for the SQL-injection lessons that follow.
Almost every real application has to remember things between requests: user accounts, orders, messages, blog posts, audit logs. That long-term memory usually lives in a database. The most common kind is a relational database — software (such as PostgreSQL, MySQL, SQLite, or SQL Server) that stores data in tables and lets you read and change it with a language called SQL (Structured Query Language, often pronounced "sequel").
Think of a single table as a spreadsheet with strict rules. Each table has a name (users, orders). Each column has a name and a fixed type — a column declared as an integer can only hold integers, a text column only text. Each row is one record: one user, one order. A relational database is "relational" because tables can reference each other (an orders row points back to the users row that placed it), and SQL lets you follow those references.
Why learn SQL on a platform that teaches C and defensive security? Because SQL injection is one of the most common and most damaging web vulnerabilities ever recorded, and you cannot understand it, find it, or fix it without first being able to read SQL. The next lesson, SQL injection and prepared statements, builds directly on this one. Here the goal is narrow and concrete: get comfortable reading and writing SELECT / INSERT / UPDATE / DELETE, and understand the WHERE, JOIN, and UNION clauses — because those last three are exactly where injection operates.
A quick note on terminology so the rest of the lesson reads smoothly:
id).SQL is the lingua franca of stored data. Banks, hospitals, shopping carts, social networks, and the dashboards behind them all speak it. If you build or audit web software, you will read SQL constantly — in application code, in logs, in incident reports.
For security specifically, reading SQL is non-negotiable. SQL injection happens when untrusted input (from a form field, URL parameter, or HTTP header) is glued directly into a SQL statement, so the input changes the meaning of the query rather than just its data. Two patterns cause most of the damage:
' OR '1'='1 turns a row filter into a condition that is always true, so the query returns every row (for example, logging in without a valid password, or dumping an entire user table).You cannot recognize, report, or remediate either pattern if you cannot read the underlying query. That is why this "basics" lesson is a security prerequisite, not a detour. (The actual exploitation-and-defense material lives in the next lesson and runs only against a local, intentionally vulnerable lab — never a system you do not own.)
Definition. A table is a named collection of rows (records). Every table defines a set of columns, and each column has a name and a fixed data type (integer, text, date, boolean, and so on).
Plain language. A table is a spreadsheet with a rigid header. The header (columns) never changes per row; every row must supply a value of the right type for each column.
How it works internally. The database stores rows on disk in a structured file and keeps indexes (sorted lookup structures, often B-trees) on key columns so it can find matching rows fast instead of scanning every row.
Table: users
+----+--------+----------------+
| id | name | email | <- columns (id:int, name:text, email:text)
+----+--------+----------------+
| 1 | ada | ada@x.com | <- row (one record)
| 2 | linus | linus@x.com |
| 3 | grace | grace@x.com |
+----+--------+----------------+
^
primary key: uniquely identifies each row
When to use / not use. Use a relational table when data is structured and you query it by fields (find user by id, list orders over $100). It is less natural for free-form documents or huge unstructured blobs.
Pitfall. Forgetting that columns are typed. Inserting 'abc' into an integer column is an error in most databases; relying on the database to silently "fix" types leads to surprises.
Knowledge check (explain in your own words): In one sentence, what is the difference between a column and a row?
Definition. These four statements cover the standard data operations, sometimes called CRUD — Create (INSERT), Read (SELECT), Update (UPDATE), Delete (DELETE).
Plain language. SELECT reads without changing anything. The other three change the table: INSERT adds rows, UPDATE edits existing rows, DELETE removes rows.
Structure.
SELECT <columns> FROM <table> [WHERE <condition>];
INSERT INTO <table> (<columns>) VALUES (<values>);
UPDATE <table> SET <col = value> [WHERE <condition>];
DELETE FROM <table> [WHERE <condition>];
When to use / not use. Use SELECT for reports and lookups; the write verbs to maintain data. Be extremely careful with UPDATE and DELETE without a WHERE — they affect every row in the table.
Pitfall. Running DELETE FROM users; with no WHERE deletes all users. A misplaced or missing WHERE is one of the most common destructive mistakes in production.
Knowledge check (predict the output): The
userstable has 3 rows. What doesUPDATE users SET email = 'oops@x.com';(no WHERE) do?
Definition. The WHERE clause restricts a statement to rows that satisfy a boolean condition.
Plain language. WHERE answers "which rows?" Only rows for which the condition is true are returned (SELECT) or changed (UPDATE/DELETE).
How it works internally. The database evaluates the condition row by row (using an index when one is available) and keeps only the matches.
SELECT * FROM users WHERE id = 42;
\_______/
condition: keep rows where id equals 42
Why it is the injection point. If application code builds the WHERE clause by concatenating user input as text, the input can inject new SQL logic. Compare:
Intended: ... WHERE name = 'ada'
Input "ada": name = 'ada' -> matches Ada only
Input "' OR '1'='1": name = '' OR '1'='1' -> always true -> ALL rows
The single quote from the input closes the string the developer opened, and the rest becomes live SQL. That is the essence of SQL injection, covered fully next lesson.
When to use / not use. Use WHERE on essentially every targeted read and every UPDATE/DELETE. Do not assemble it from raw user input — use parameters/prepared statements (next lesson).
Pitfall. Using = to compare with NULL. In SQL, WHERE col = NULL is never true; you must write WHERE col IS NULL.
Knowledge check (find the bug): A login query is built as
"SELECT * FROM users WHERE name = '" + input + "'". What does the attacker type to make it return every user?
Definition. A JOIN produces rows by matching rows from two tables on a condition, usually a foreign key = primary key relationship.
Plain language. JOIN "glues" related rows together so one result row can contain columns from both tables.
users orders
+----+-------+ +----+---------+-------+
| id | name | | id | user_id | total |
+----+-------+ +----+---------+-------+
| 1 | ada | <------\ | 10 | 1 | 50 |
| 2 | linus | \-| 11 | 1 | 20 |
+----+-------+ | 12 | 2 | 99 |
+----+---------+-------+
JOIN ON orders.user_id = users.id
=>
ada | 50
ada | 20
linus | 99
How it works internally. The engine pairs rows that satisfy the ON condition (often using an index on the join column). An INNER JOIN (the default JOIN) keeps only rows that match on both sides; a LEFT JOIN keeps every row from the left table even when there is no match (filling missing right-side columns with NULL).
When to use / not use. Use JOIN to follow relationships (a user and their orders). Avoid joining on non-indexed columns over huge tables without thought — it can be slow.
Pitfall. Forgetting the ON condition. A JOIN with no condition produces a cross join — every row of one table paired with every row of the other — which explodes the result size.
Knowledge check (concept): With INNER JOIN, what happens to a user who has no orders at all — do they appear in the result?
Definition. UNION combines the rows of two SELECT statements into one result set.
Plain language. JOIN combines tables side by side (more columns); UNION stacks results on top of each other (more rows).
Strict rules. The two SELECTs must return the same number of columns, in the same order, with compatible types. UNION removes duplicate rows; UNION ALL keeps duplicates (and is faster).
SELECT name, email FROM customers -- 2 columns
UNION
SELECT name, email FROM suppliers; -- must also be 2 compatible columns
=> one list containing both customers and suppliers
Why attackers love it. If a page runs a SELECT with, say, two visible columns, an attacker who can inject SQL may append UNION SELECT username, password_hash FROM users to read data from a different table through the original page. This is union-based SQL injection — and it is exactly why the column-count rule matters to an attacker (they must match the original query's column count). Defenders prevent it the same way they prevent all injection: parameterized queries and least-privilege database accounts.
When to use / not use. Use UNION to merge similar lists from different tables. Reach for UNION ALL when you do not need duplicate removal.
Pitfall. Mismatched column counts or types — SELECT a, b ... UNION SELECT a ... fails because the column counts differ.
Knowledge check (predict the output):
SELECT 'x' UNION SELECT 'x';returns how many rows, and why?
Core SQL shapes, annotated. Square brackets mark optional parts; they are not SQL syntax.
-- READ: pick columns from a table, optionally filtered and ordered
SELECT col1, col2 -- which columns (use * for all, but name them in real code)
FROM table_name -- source table
WHERE condition -- which rows (optional)
ORDER BY col1 ASC -- sort (optional; ASC ascending, DESC descending)
LIMIT 10; -- cap how many rows come back (optional)
-- CREATE: add a row
INSERT INTO table_name (col1, col2) -- target columns
VALUES ('text value', 123); -- matching values, in the same order
-- UPDATE: change existing rows (WHERE is critical!)
UPDATE table_name
SET col1 = 'new value'
WHERE id = 42; -- without WHERE, every row is updated
-- DELETE: remove rows (WHERE is critical!)
DELETE FROM table_name
WHERE id = 42; -- without WHERE, every row is deleted
-- JOIN: combine related tables
SELECT u.name, o.total
FROM users u -- 'u' is a table alias (shorthand)
JOIN orders o ON o.user_id = u.id; -- match condition
-- UNION: stack two result sets (same column count + compatible types)
SELECT name FROM customers
UNION ALL -- ALL keeps duplicates; plain UNION removes them
SELECT name FROM suppliers;
Conventions worth adopting early: SQL keywords are conventionally written in UPPERCASE (the database does not care, but it aids readability); statements end with a semicolon; single quotes '...' delimit text values; double quotes are for identifiers in standard SQL (databases vary). Prefer naming columns explicitly over SELECT * in application code so results stay stable when the schema changes.
Most web apps store data in a relational database (data organized into linked tables) and query it with SQL. You don't need to be a database administrator, but you must be able to read SQL to understand injection.
Data lives in tables: rows of records, with typed columns. The four core statements are:
SELECT name, email FROM users WHERE id = 42;
INSERT INTO users (name, email) VALUES ('ada', 'ada@x.com');
UPDATE users SET email = 'new@x.com' WHERE id = 42;
DELETE FROM users WHERE id = 42;
The WHERE clause filters rows. It is also exactly where SQL injection strikes.
If user input is concatenated directly into the WHERE clause, an input like ' OR '1'='1 turns the filter into a condition that is always true. The query then returns every row.
JOIN combines rows from two tables based on a relationship between them:
SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id;
Here each user row is matched to its order rows where user_id equals the user's id.
UNION stacks the results of two SELECT statements that have matching columns.
Attackers abuse UNION SELECT to append their own query (for example, one that pulls password hashes) onto an injectable one. This is union-based SQL injection.
Reading the four verbs plus WHERE, JOIN, and UNION is enough to follow how injection works.
Below is a complete, runnable SQLite script. SQLite ships with most systems and needs no server, so you can paste this into a file (e.g. shop.sql) and run sqlite3 :memory: < shop.sql, or paste it line by line into the sqlite3 prompt. It builds a tiny shop schema and exercises every clause from this lesson.
-- shop.sql : a minimal, self-contained demo of SQL basics
-- 1. Define the schema (two related tables)
CREATE TABLE users (
id INTEGER PRIMARY KEY, -- unique row identifier
name TEXT NOT NULL, -- required text
email TEXT NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL, -- foreign key -> users.id
total INTEGER NOT NULL, -- amount in whole dollars
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 2. INSERT: add some rows
INSERT INTO users (id, name, email) VALUES
(1, 'ada', 'ada@x.com'),
(2, 'linus', 'linus@x.com'),
(3, 'grace', 'grace@x.com'); -- grace will have no orders
INSERT INTO orders (id, user_id, total) VALUES
(10, 1, 50),
(11, 1, 20),
(12, 2, 99);
-- 3. SELECT + WHERE: read one user by id
SELECT name, email FROM users WHERE id = 1;
-- 4. UPDATE + WHERE: change one row only
UPDATE users SET email = 'ada@new.com' WHERE id = 1;
SELECT name, email FROM users WHERE id = 1; -- confirm the change
-- 5. JOIN: each user paired with their order totals (INNER JOIN)
SELECT u.name, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
ORDER BY u.name;
-- 6. UNION: stack two reads with matching column shapes
SELECT name FROM users WHERE id = 2
UNION
SELECT name FROM users WHERE id = 3;
-- 7. DELETE + WHERE: remove a single order
DELETE FROM orders WHERE id = 11;
SELECT COUNT(*) FROM orders; -- how many orders remain?
What it does. Steps 1-2 create the schema and load three users and three orders. Step 3 reads Ada by primary key. Step 4 updates only Ada's email and re-reads it to prove the change is isolated. Step 5 joins users to orders, so each result row carries a name and a total. Step 6 unions two single-row reads into one list. Step 7 deletes one order and counts what is left.
Expected output (in order):
ada|ada@x.com -- step 3
ada|ada@new.com -- step 4 (after update)
ada|50 -- step 5 (JOIN), sorted by name
ada|20
linus|99
linus -- step 6 (UNION)
grace
2 -- step 7: 3 orders minus 1 deleted = 2
Edge cases to notice. Grace (id 3) never appears in the JOIN result because she has no matching orders row — that is INNER JOIN behavior; a LEFT JOIN would include her with a NULL total. The UNION in step 6 would collapse duplicates if both reads returned the same name; UNION ALL would keep both. And if you removed the WHERE in step 4 or step 7, you would change or delete every row — the single most common destructive SQL mistake.
Walking through the key statements and tracing how the data changes.
Step 1 - CREATE TABLE. Two tables are defined. users.id is the primary key (unique per row). orders.user_id is a foreign key that must hold a value present in users.id; this is the relationship JOIN will follow. NOT NULL forbids missing values in those columns.
Step 2 - INSERT. Three users and three orders are loaded. State afterward:
users: (1 ada), (2 linus), (3 grace)
orders: (10 -> user 1, $50), (11 -> user 1, $20), (12 -> user 2, $99)
Step 3 - SELECT ... WHERE id = 1. The engine scans (or index-seeks) for rows where id equals 1, finds Ada, and returns the two requested columns: ada|ada@x.com.
Step 4 - UPDATE ... WHERE id = 1. Only Ada's row matches the condition, so only her email becomes ada@new.com. Linus and Grace are untouched. The follow-up SELECT confirms ada|ada@new.com. Trace the importance of WHERE: if it were absent, all three emails would change.
Step 5 - JOIN. For every users row u, the engine looks for orders rows o where o.user_id = u.id:
| users row | matching orders | result rows |
|---|---|---|
| ada (1) | 10 ($50), 11 ($20) | ada|50, ada|20 |
| linus (2) | 12 ($99) | linus|99 |
| grace (3) | none | (dropped by INNER JOIN) |
After ORDER BY u.name, the output is the three ada/ada/linus rows shown earlier. Grace is absent because INNER JOIN keeps only matched pairs.
Step 6 - UNION. The first SELECT returns linus; the second returns grace. UNION stacks them into a two-row list. Both SELECTs return one text column, so the column-count and type rules are satisfied. Because the two values differ, UNION's duplicate removal has nothing to remove.
Step 7 - DELETE ... WHERE id = 11. One order matches and is removed. The remaining orders are 10 and 12, so COUNT(*) returns 2.
Security-flavored trace. Imagine step 3 were built by concatenation: "SELECT name, email FROM users WHERE id = " + input. With input = "1" you get the intended single row. With input = "1 OR 1=1" the WHERE becomes id = 1 OR 1=1 — always true — returning every user. That single substitution is how an injection turns a one-row lookup into a full table dump; remembering this trace is the whole point of the lesson.
Mistake 1 - UPDATE/DELETE with no WHERE.
-- WRONG: changes every row
UPDATE users SET email = 'x@x.com';
Why it is wrong: with no WHERE, the condition defaults to "all rows." You just overwrote every user's email. The corrected form scopes the change:
-- RIGHT
UPDATE users SET email = 'x@x.com' WHERE id = 42;
Prevent it by writing the WHERE first, and by testing the same condition with a SELECT before running the UPDATE/DELETE.
Mistake 2 - comparing to NULL with =.
-- WRONG: returns nothing, even for rows with no email
SELECT * FROM users WHERE email = NULL;
-- RIGHT
SELECT * FROM users WHERE email IS NULL;
Why: in SQL's three-valued logic, any comparison with NULL yields "unknown," never "true." Recognize it when a query that should match empty values returns zero rows.
Mistake 3 - mismatched UNION shapes.
-- WRONG: 2 columns vs 1 column
SELECT name, email FROM customers
UNION
SELECT name FROM suppliers;
Why: UNION requires identical column counts and compatible types. Fix by making both SELECTs return the same shape (e.g. add the missing column or drop the extra one).
Mistake 4 - JOIN without an ON condition (accidental cross join).
-- WRONG: every user paired with every order
SELECT * FROM users, orders;
Why: omitting the relationship produces the Cartesian product (3 users x 3 orders = 9 rows). The corrected form states the relationship:
SELECT * FROM users u JOIN orders o ON o.user_id = u.id;
Mistake 5 (the dangerous one) - building WHERE from raw input.
-- WRONG (conceptually): user text concatenated into SQL
-- "SELECT * FROM users WHERE name = '" + name + "'"
Why: the input can break out of the string and inject logic (' OR '1'='1). This is SQL injection. The fix is parameterized / prepared statements, where input is sent as data, never as SQL text — that is the entire subject of the next lesson. Recognize the smell whenever you see string concatenation that mixes SQL keywords and untrusted input.
"near "...": syntax error". The most common SQL error. Usually a missing comma between columns, a stray or missing single quote around a text value, a missing semicolon, or a keyword typo. Read the message: most engines point at the token where parsing broke.
"no such table" / "no such column". The name is misspelled, the table was never created in this session (in-memory databases reset every run), or you forgot a table alias prefix in a JOIN (o.total vs total). List the schema to check: .tables and .schema users in SQLite.
"UNION ... have different number of columns" (or similar). The two SELECTs do not match in column count. Count the columns on each side and align them.
A query returns 0 rows when you expected some. Check the WHERE: are you comparing text without quotes, comparing to NULL with =, or filtering on the wrong column? Temporarily drop the WHERE to confirm the rows exist, then add conditions back one at a time.
A JOIN returns too many rows. You likely have a missing or wrong ON condition (cross join), or you are joining on a non-unique column so rows multiply. Re-check the ON clause and confirm the join key is what you think it is.
Concrete debugging routine.
Questions to ask when it does not work. Does this table/column exist and is it spelled right? Are my text values quoted? Is my WHERE matching the rows I think? Do my UNION sides have the same shape? Does my JOIN have a correct ON condition?
This is a concept lesson, so there is no C memory model here — but the database equivalents of "memory safety" are data integrity and robustness, and they matter just as much.
NOT NULL, type, and FOREIGN KEY constraints are your safety rails — define them rather than trusting application code alone.BEGIN; ... ROLLBACK/COMMIT;) so you can undo a mistake.Where this shows up. SQL backs an enormous share of production software: e-commerce catalogs and orders, banking ledgers, hospital records, content management systems, analytics dashboards, and the user/session tables behind almost every login. SQLite (a single-file SQL database) is embedded in web browsers, phones, and countless desktop apps. Reading SQL is a daily skill for backend developers, data analysts, DBAs, and security engineers alike.
A concrete scenario. A web shop renders an order-history page by joining users to orders and filtering by the logged-in user's id — exactly the JOIN + WHERE pattern from this lesson. The same query, if built by concatenating the user id from the URL, is also the textbook injection target.
Professional best practices.
Beginner habits to build now:
SELECT * so results stay stable.NOT NULL and key constraints.Advanced habits:
Work these against the SQLite shop schema from the code section (recreate it if your in-memory database has reset).
Beginner 1 - Read with a filter.
name and email of the user whose id is 2.linus|linus@x.com.*.Beginner 2 - Insert and verify.
mads, email mads@x.com), then SELECT it back to confirm.id must be unique and name/email are NOT NULL.Intermediate 1 - Scoped update.
mads@new.com without touching any other row.Intermediate 2 - Join with a sum.
users to orders, group by user, and sum total.SUM, GROUP BY).GROUP BY u.name and select SUM(o.total).Challenge - Combine two sources, then reason about injection.
grace, with no duplicate emails.total, the second filters by name.email) so the UNION is valid; duplicates must be removed.total would become if the threshold 40 were supplied by concatenating raw user input as 40 OR 1=1, and which rows the query would then return. Do not write any exploit — just describe the effect and state the correct defensive fix.UNION SELECT for union-based injection.=, mismatched UNION shapes, ON-less JOINs, and — the dangerous one — concatenating user input into SQL.