SQL basics: DDL, constraints, SELECT and joins
Creating tables (CREATE TABLE and constraints), querying a single table and joining tables (inner, natural, outer, self join). The basis of the "write the SQL" questions on the midterm.
SQL (Structured Query Language) is the standard language for talking to a relational database. In weeks four and five you wrote what you want mathematically with relational algebra. SQL writes the same idea in a readable language. The “write an SQL query that…” questions earn the most points in the exam. On this page you will learn two things: building tables (DDL) and getting data out of tables (SELECT and joins).
Golden rules:
- The reading order of SELECT-FROM-WHERE is FROM → WHERE → SELECT. First build the table, then filter the rows, and select the columns last.
- SQL does not remove duplicates. In relational algebra drops duplicates; in SQL you have to write
DISTINCTfor that. - Every join must have a condition. A bare
FROM r, sis a Cartesian product. - NULL equals nothing, not even itself. If you are looking for NULL, write
IS NULL. - A “those with no match” question = outer join + IS NULL (or NOT EXISTS from week 7).
The sample database
We use the same small university database throughout the course. Six tables:
| Table | Columns (the first column is the key; parentheses = composite key) | Rows |
|---|---|---|
| department | dept_name, building, budget | CS Galata 500000, EE Galata 350000, MATH Rumeli 200000, BIO Kadikoy 150000 |
| instructor | inst_id, name, dept_name, salary | I1 Aksoy CS 95000, I2 Demir CS 80000, I3 Kaya EE 85000, I4 Sahin MATH 70000, I5 Celik MATH 72000, I6 Yildiz BIO 60000 |
| student | stu_id, name, dept_name, tot_cred | S1 Ada CS 60, S2 Bora CS 30, S3 Cem EE 45, S4 Defne MATH 90, S5 Ece CS 15, S6 Firat EE 0, S7 Gizem NULL 10 |
| course | course_id, title, dept_name, credits | CS101 Intro to Programming 4, CS201 Data Structures 4, CS305 Databases 3, EE210 Circuits 3, MA101 Calculus 4, MA202 Linear Algebra 3, BI100 Biology 3 |
| teaches | (inst_id, course_id) | I1-CS101, I1-CS305, I2-CS201, I3-EE210, I4-MA101, I5-MA202 |
| takes | (stu_id, course_id), grade | S1: CS101 A, CS201 B, CS305 A, MA101 B · S2: CS101 B, CS201 C · S3: EE210 A, MA101 C, CS101 NULL · S4: MA101 A, MA202 A, CS305 B · S5: CS101 F · S7: MA202 B |
Pay attention: Firat (S6) takes no course, Gizem’s department is NULL, Yildiz teaches nothing, nobody teaches or takes Biology, and Cem’s CS101 grade is still NULL. These gaps are the material for the outer join and NULL questions.
DDL: building the table
DDL (Data Definition Language) defines the schema. It creates structure, not rows.
CREATE TABLE course (
course_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
dept_name TEXT REFERENCES department(dept_name),
credits INTEGER CHECK (credits BETWEEN 1 AND 6)
);
CREATE TABLE takes (
stu_id TEXT REFERENCES student(stu_id),
course_id TEXT REFERENCES course(course_id),
grade TEXT,
PRIMARY KEY (stu_id, course_id)
);
Commonly used data types: INT/INTEGER, NUMERIC(p,d) (p digits, d of them decimal), VARCHAR(n) (at most n characters), CHAR(n) (fixed n), DATE, REAL. SQLite is loose about types (TEXT, INTEGER, REAL); standard SQL and other systems are stricter.
Integrity constraints
| Constraint | What it does | Violation example (SQLite message) |
|---|---|---|
PRIMARY KEY | unique and cannot be NULL | INSERT INTO student VALUES ('S1','Ali','CS',0) → UNIQUE constraint failed: student.stu_id |
NOT NULL | cannot be left empty | instructor name NULL → NOT NULL constraint failed |
UNIQUE | no repeats (candidate key) | two clubs with the same name → UNIQUE constraint failed |
CHECK (P) | every row must satisfy P | credits = 8 → CHECK constraint failed |
DEFAULT v | v if no value is given | insert without tot_cred → 0 |
FOREIGN KEY / REFERENCES | the value must exist in the target table | S99 into takes → FOREIGN KEY constraint failed |
A foreign key protects from both directions. You cannot add an enrollment for a non-existent student. You also cannot delete a student who has courses: DELETE FROM student WHERE stu_id='S1' gives an error, because takes still points to S1. If you want to allow deletion, you choose the behavior:
CREATE TABLE advisor (
stu_id TEXT PRIMARY KEY REFERENCES student(stu_id) ON DELETE CASCADE,
inst_id TEXT NOT NULL REFERENCES instructor(inst_id)
);
With ON DELETE CASCADE, when the student is deleted the advisor row is deleted on its own. The other options are ON DELETE SET NULL and the default behavior, which is to reject.
PITFALL · FK checking is off by default in SQLite. SQLite enforces foreign keys only after
PRAGMA foreign_keys = ON;is run. Our verification script turns it on. In standard SQL and in PostgreSQL this check is always on.
ALTER and DROP
ALTER TABLE student ADD COLUMN email TEXT; -- email = NULL in the existing rows
DROP TABLE club; -- the table and its schema are completely gone
DELETE FROM club; -- the rows go, an empty table remains
Basic query: SELECT-FROM-WHERE
SELECT name, salary FROM instructor WHERE dept_name = 'CS' AND salary > 85000;
| name | salary |
|---|---|
| Aksoy | 95000 |
In relational algebra this query means . The mapping is:
| SQL | Relational algebra |
|---|---|
FROM r, s | |
WHERE P | |
SELECT DISTINCT A | |
AS |
DISTINCT and duplicates
SELECT dept_name FROM instructor returns 6 rows: CS, CS, EE, MATH, MATH, BIO. SQL returns a multiset (bag). When you add DISTINCT:
| dept_name |
|---|
| CS |
| EE |
| MATH |
| BIO |
AS and arithmetic
SELECT name, salary / 12 AS monthly FROM instructor WHERE dept_name = 'CS';
| name | monthly |
|---|---|
| Aksoy | 7916 |
| Demir | 6666 |
PITFALL · integer division. In SQLite (and in PostgreSQL) the quotient of two INTEGERs is an integer, and the decimal part is dropped. If you write
salary / 12.0, you get 7916.666666666667 and 6666.666666666667.
LIKE, BETWEEN, IN
LIKE matches patterns: % is a string of any length (it may be empty), _ is exactly one character.
SELECT course_id, title FROM course WHERE title LIKE '%a%s';
| course_id | title |
|---|---|
| CS201 | Data Structures |
| CS305 | Databases |
| MA101 | Calculus |
The pattern says “there is an a somewhere inside and it ends with s”. Circuits ends with s but has no a inside. course_id LIKE 'CS_01' finds CS101 and CS201.
PITFALL · LIKE and uppercase. In standard SQL,
LIKEis case-sensitive. SQLite is case-insensitive for ASCII letters:title LIKE 'data%'finds Data Structures and Databases in SQLite, and finds nothing in PostgreSQL.
BETWEEN includes both ends:
SELECT name, salary FROM instructor WHERE salary BETWEEN 70000 AND 85000;
| name | salary |
|---|---|
| Demir | 80000 |
| Kaya | 85000 |
| Sahin | 70000 |
| Celik | 72000 |
IN asks for membership in a list: WHERE dept_name IN ('EE', 'MATH') → Cem, Defne, Firat. This is the same as dept_name = 'EE' OR dept_name = 'MATH'.
ORDER BY
SELECT name, dept_name, salary FROM instructor ORDER BY dept_name ASC, salary DESC;
| name | dept_name | salary |
|---|---|---|
| Yildiz | BIO | 60000 |
| Aksoy | CS | 95000 |
| Demir | CS | 80000 |
| Kaya | EE | 85000 |
| Celik | MATH | 72000 |
| Sahin | MATH | 70000 |
First ascending by department, and within the same department descending by salary. Without ORDER BY, the order of rows is not guaranteed. For the first n rows, SQLite and MySQL use LIMIT n, and standard SQL uses FETCH FIRST n ROWS ONLY (SQLite does not recognize the latter).
Join: combining tables
Inner join and three ways to write it
The courses taken by EE students:
SELECT s.name, t.course_id, t.grade
FROM student s JOIN takes t ON s.stu_id = t.stu_id
WHERE s.dept_name = 'EE';
| name | course_id | grade |
|---|---|---|
| Cem | CS101 | NULL |
| Cem | EE210 | A |
| Cem | MA101 | C |
Four ways to write it that give the same result (all were run in the script):
FROM student s JOIN takes t ON s.stu_id = t.stu_id -- ON: you write the condition
FROM student JOIN takes USING (stu_id) -- USING: equate on these columns
FROM student NATURAL JOIN takes -- ALL columns with the same name
FROM student, takes WHERE student.stu_id = takes.stu_id -- old style
Firat is not in this result. An inner join keeps only the rows that have a partner.
The NATURAL JOIN trap
student JOIN takes USING (stu_id) JOIN course USING (course_id) gives 14 rows, each record once. But:
SELECT name, course_id, dept_name FROM student NATURAL JOIN takes NATURAL JOIN course;
returns only 9 rows. The reason: student and course both have dept_name. NATURAL JOIN equates those too, and the query silently turns into the question “courses the student took from their own department”. Ada’s MA101, Cem’s MA101 and CS101, Defne’s CS305 and Gizem’s MA202 disappear.
Outer join
An inner join drops rows with no partner. An outer join keeps them and fills the missing side with NULL.
LEFT JOIN: all rows on the left.RIGHT JOIN: all rows on the right.FULL OUTER JOIN: all rows on both sides.
Students who have taken no course:
SELECT s.stu_id, s.name, t.course_id
FROM student s LEFT JOIN takes t ON s.stu_id = t.stu_id
WHERE t.course_id IS NULL;
| stu_id | name | course_id |
|---|---|---|
| S6 | Firat | NULL |
Students and their departments, including the orphans on both sides:
SELECT s.name, d.dept_name, d.building
FROM student s FULL OUTER JOIN department d ON s.dept_name = d.dept_name;
| name | dept_name | building |
|---|---|---|
| Ada | CS | Galata |
| Bora | CS | Galata |
| Cem | EE | Galata |
| Defne | MATH | Rumeli |
| Ece | CS | Galata |
| Firat | EE | Galata |
| Gizem | NULL | NULL |
| NULL | BIO | Kadikoy |
Because Gizem’s department is NULL, she matches no department. BIO has no students.
PITFALL · FULL OUTER JOIN is not everywhere. Before 3.39 (2022), SQLite did not support
RIGHTandFULLjoins; the queries on this page were run on 3.53. MySQL still does not supportFULL OUTER JOIN. There you build the same result by taking theLEFT JOINresult and doing aUNIONwith the unmatched rows on the right.
Self join
You use the same table under two different aliases. Instructors who earn more than another instructor in the same department:
SELECT a.name AS higher, b.name AS lower
FROM instructor a JOIN instructor b
ON a.dept_name = b.dept_name AND a.salary > b.salary;
| higher | lower |
|---|---|
| Aksoy | Demir |
| Celik | Sahin |
The alias is mandatory: instructor.salary > instructor.salary cannot say which is which.
Common mistakes
| Wrong | Right |
|---|---|
FROM student, department and no join condition | Cartesian product: 7 × 4 = 28 rows. Add the condition. |
WHERE dept_name = NULL | No row comes back. WHERE dept_name IS NULL |
| Thinking an SQL result is a set | Duplicates stay; use DISTINCT if you want unique rows |
Joining 3 tables with NATURAL JOIN | Hidden common columns (dept_name) are equated too; use USING or ON |
LEFT JOIN ... ON ... AND t.x IS NULL | It does not filter, all left rows come back. Put the IS NULL condition in WHERE |
Thinking BETWEEN 70000 AND 85000 excludes the ends | Both ends are included |
Thinking salary / 12 gives a decimal | INTEGER / INTEGER is an integer; divide by 12.0 |
a.id <> b.id in a self join | Each pair comes twice; use a.id < b.id |
Expecting an order without ORDER BY | The order is undefined |
Deleting rows with DROP TABLE | The table is gone completely; use DELETE FROM for rows |
Test yourself
Exam-style questions. Solve them on paper first, then reveal the answer.
Using the university database, write an SQL query that lists the names of CS students who have at least 30 total credits.
Show answer
One table is enough: student. The two conditions are joined with AND. "At least" means >=.
CS students: Ada (60), Bora (30), Ece (15). Those with 30 or more: Ada, Bora. Bora is exactly on the limit; if you had written >, he would be lost.
How many rows does SELECT DISTINCT building FROM department; return? How many does it return without DISTINCT?
Show answer
The department table has 4 rows, and the buildings are: Galata, Galata, Rumeli, Kadikoy.
By default SQL does not remove duplicates (multiset). Without DISTINCT 4 rows come back, with Galata twice. With DISTINCT, 3.
Write an SQL query that lists the titles of all courses taken by the student named 'Ada'.
Show answer
The name is in student, the course title is in course, and takes connects the two. Three tables, two join conditions.
Ada = S1. The takes rows of S1: CS101, CS201, CS305, MA101. Titles: Intro to Programming, Data Structures, Databases, Calculus.
Write an SQL query that lists the names of instructors whose salary is between 72000 and 85000 (inclusive), highest salary first.
Show answer
BETWEEN a AND b includes both ends, that is, salary >= 72000 AND salary <= 85000.
Matches: Kaya 85000, Demir 80000, Celik 72000. With ORDER BY salary DESC the result is: Kaya, Demir, Celik.
Write an SQL query that lists (without duplicates) the names of instructors who teach at least one 4-credit course.
Show answer
The 4-credit courses: CS101, CS201, MA101. Their instructors: I1 Aksoy (CS101), I2 Demir (CS201), I4 Sahin (MA101).
If an instructor taught more than one 4-credit course, the name would come twice; since the question says "without duplicates", DISTINCT is required.
Result: Aksoy, Demir, Sahin.
Write an SQL query that lists the titles of courses that no instructor teaches. Use an outer join.
Show answer
LEFT JOIN also keeps the course rows that have no partner and fills the teaches columns with NULL. Then WHERE t.inst_id IS NULL keeps only the unmatched ones.
Result: Biology (BI100).
Pitfall: if you put the IS NULL condition inside ON, it does not filter; all 7 courses come back. The condition must be in WHERE.
A student wrote the query below to list each student's name with the building of their department, for departments with a budget over 300000. How many rows does it return, and what is the bug?
SELECT name, building
FROM student, department
WHERE budget > 300000;Show answer
FROM student, department is a Cartesian product: 7 × 4 = 28 rows. WHERE filters only the budget: CS and EE remain, 7 × 2 = 14 rows. Every student is paired with two departments, which is meaningless.
Fix: WHERE student.dept_name = department.dept_name AND budget > 300000 (or JOIN ... ON). The correct query returns 5 rows: Ada, Bora, Cem, Ece, Firat (all Galata).
Write an SQL query that returns every pair of distinct students who are in the same department, each pair listed only once, as (s1, s2) student IDs.
Show answer
A self join that uses the same table under two aliases.a.stu_id < b.stu_id does two jobs at once: it removes matching a student with themselves (S1,S1) and the reverse repeat (S2,S1). If you had written <>, each pair would come twice (8 rows).
Result: (S1,S2), (S1,S5), (S2,S5), (S3,S6). Gizem's department is NULL; NULL = NULL is not true, so she matches nobody.
How many rows does the query below return? (Correct join on stu_id and course_id gives 14 rows.)
SELECT name, course_id
FROM student NATURAL JOIN takes NATURAL JOIN course;Show answer
NATURAL JOIN equates all columns with the same name. Both student and course have dept_name, so a hidden condition student.dept_name = course.dept_name is added as well.
The courses outside the student's own department drop out: Ada-MA101, Cem-MA101, Cem-CS101, Defne-CS305, Gizem-MA202 (Gizem's department is NULL). 14 − 5 = 9.
That is why JOIN ... USING (...) or ON is safer with more than two tables.
How many rows does this full outer join return, and which rows contain NULLs?
SELECT s.name, d.dept_name
FROM student s FULL OUTER JOIN department d
ON s.dept_name = d.dept_name;Show answer
Matching rows: 6 students (except Gizem) with their own departments, 6 rows.
Unmatched on the left: Gizem (her department is NULL), the right side comes as NULL.
Unmatched on the right: the BIO department has no students, the left side comes as NULL.
6 + 1 + 1 = 8. Note: SQLite has supported FULL OUTER JOIN since version 3.39; MySQL does not support it at all (there it is written with LEFT JOIN + UNION).
Which statement fails, and which constraint does it violate? (a) INSERT INTO student (stu_id, name, dept_name) VALUES ('S8', 'Hakan', 'CS'); (b) INSERT INTO course VALUES ('CS999', 'Capstone', 'CS', 8);
Show answer
(a) Valid. tot_cred was not given, so DEFAULT 0 applies. S8 is a new key, and CS is an existing department.
(b) The credits column has CHECK (credits BETWEEN 1 AND 6); 8 is not in this range. SQLite message: CHECK constraint failed.
Write a CREATE TABLE statement for advisor(stu_id, inst_id): each student has at most one advisor, inst_id is required and must be an existing instructor, and when a student is deleted their advisor row must be deleted automatically.
Show answer
"At most one advisor per student": stu_id alone is the key, that is, PRIMARY KEY.
"Required": NOT NULL. "Existing instructor": REFERENCES instructor(inst_id).
"Deleted automatically": ON DELETE CASCADE; when the student is deleted, the row tied to them is deleted too.
In the script, deleting S6 was seen to remove the advisor row as well, and inserting a non-existent I99 gave an FK error.
Verification: Every SQL statement on the page and every question answer was run with node:sqlite (SQLite 3.53.4) on the sample university database; result tables were taken verbatim from the script output, and constraint violations were verified with the error messages (verify/SEN2104/06.mjs, shared data lib.mjs).