SEN2104 · Week 6 · Structured Query Language (SQL)

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.

DDLDMLCREATE TABLEPRIMARY KEYFOREIGN KEYNOT NULLUNIQUECHECKDEFAULTON DELETE CASCADEALTER TABLEDROP TABLESELECT-FROM-WHEREDISTINCTalias (AS)LIKEBETWEENINORDER BYinner joinnatural joinJOIN USINGouter joinself joinCartesian product

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:

  1. The reading order of SELECT-FROM-WHERE is FROM → WHERE → SELECT. First build the table, then filter the rows, and select the columns last.
  2. SQL does not remove duplicates. In relational algebra π\pi drops duplicates; in SQL you have to write DISTINCT for that.
  3. Every join must have a condition. A bare FROM r, s is a Cartesian product.
  4. NULL equals nothing, not even itself. If you are looking for NULL, write IS NULL.
  5. 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:

TableColumns (the first column is the key; parentheses = composite key)Rows
departmentdept_name, building, budgetCS Galata 500000, EE Galata 350000, MATH Rumeli 200000, BIO Kadikoy 150000
instructorinst_id, name, dept_name, salaryI1 Aksoy CS 95000, I2 Demir CS 80000, I3 Kaya EE 85000, I4 Sahin MATH 70000, I5 Celik MATH 72000, I6 Yildiz BIO 60000
studentstu_id, name, dept_name, tot_credS1 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
coursecourse_id, title, dept_name, creditsCS101 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), gradeS1: 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

ConstraintWhat it doesViolation example (SQLite message)
PRIMARY KEYunique and cannot be NULLINSERT INTO student VALUES ('S1','Ali','CS',0) → UNIQUE constraint failed: student.stu_id
NOT NULLcannot be left emptyinstructor name NULL → NOT NULL constraint failed
UNIQUEno repeats (candidate key)two clubs with the same name → UNIQUE constraint failed
CHECK (P)every row must satisfy Pcredits = 8 → CHECK constraint failed
DEFAULT vv if no value is giveninsert without tot_cred → 0
FOREIGN KEY / REFERENCESthe value must exist in the target tableS99 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;
namesalary
Aksoy95000

In relational algebra this query means πname, salary(σdept_name=′CS′∧salary>85000(instructor))\pi_{name,\,salary}(\sigma_{dept\_name='CS' \land salary>85000}(instructor)). The mapping is:

SQLRelational algebra
FROM r, sr×sr \times s
WHERE PσP\sigma_P
SELECT DISTINCT AπA\pi_A
ASρ\rho

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';
namemonthly
Aksoy7916
Demir6666

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_idtitle
CS201Data Structures
CS305Databases
MA101Calculus

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, LIKE is 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;
namesalary
Demir80000
Kaya85000
Sahin70000
Celik72000

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;
namedept_namesalary
YildizBIO60000
AksoyCS95000
DemirCS80000
KayaEE85000
CelikMATH72000
SahinMATH70000

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';
namecourse_idgrade
CemCS101NULL
CemEE210A
CemMA101C

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_idnamecourse_id
S6FiratNULL

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;
namedept_namebuilding
AdaCSGalata
BoraCSGalata
CemEEGalata
DefneMATHRumeli
EceCSGalata
FiratEEGalata
GizemNULLNULL
NULLBIOKadikoy

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 RIGHT and FULL joins; the queries on this page were run on 3.53. MySQL still does not support FULL OUTER JOIN. There you build the same result by taking the LEFT JOIN result and doing a UNION with 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;
higherlower
AksoyDemir
CelikSahin

The alias is mandatory: instructor.salary > instructor.salary cannot say which is which.

Common mistakes

WrongRight
FROM student, department and no join conditionCartesian product: 7 × 4 = 28 rows. Add the condition.
WHERE dept_name = NULLNo row comes back. WHERE dept_name IS NULL
Thinking an SQL result is a setDuplicates stay; use DISTINCT if you want unique rows
Joining 3 tables with NATURAL JOINHidden common columns (dept_name) are equated too; use USING or ON
LEFT JOIN ... ON ... AND t.x IS NULLIt does not filter, all left rows come back. Put the IS NULL condition in WHERE
Thinking BETWEEN 70000 AND 85000 excludes the endsBoth ends are included
Thinking salary / 12 gives a decimalINTEGER / INTEGER is an integer; divide by 12.0
a.id <> b.id in a self joinEach pair comes twice; use a.id < b.id
Expecting an order without ORDER BYThe order is undefined
Deleting rows with DROP TABLEThe table is gone completely; use DELETE FROM for rows

Test yourself

Exam-style questions. Solve them on paper first, then reveal the answer.

Question 1easy

Using the university database, write an SQL query that lists the names of CS students who have at least 30 total credits.

Show answer
SELECT name FROM student WHERE dept_name = 'CS' AND tot_cred >= 30;

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.

Question 2easy

How many rows does SELECT DISTINCT building FROM department; return? How many does it return without DISTINCT?

Show answer
3 rows (Galata, Rumeli, Kadikoy); 4 without DISTINCT

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.

Question 3medium

Write an SQL query that lists the titles of all courses taken by the student named 'Ada'.

Show answer
SELECT c.title FROM student s JOIN takes t ON s.stu_id = t.stu_id JOIN course c ON t.course_id = c.course_id WHERE s.name = 'Ada';

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.

Question 4easy

Write an SQL query that lists the names of instructors whose salary is between 72000 and 85000 (inclusive), highest salary first.

Show answer
SELECT name FROM instructor WHERE salary BETWEEN 72000 AND 85000 ORDER BY salary DESC;

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.

Question 5medium

Write an SQL query that lists (without duplicates) the names of instructors who teach at least one 4-credit course.

Show answer
SELECT DISTINCT i.name FROM instructor i JOIN teaches t ON i.inst_id = t.inst_id JOIN course c ON t.course_id = c.course_id WHERE c.credits = 4;

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.

Question 6medium

Write an SQL query that lists the titles of courses that no instructor teaches. Use an outer join.

Show answer
SELECT c.title FROM course c LEFT JOIN teaches t ON c.course_id = t.course_id WHERE t.inst_id IS NULL;

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.

Question 7medium

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
14 rows; the join condition student.dept_name = department.dept_name is missing (Cartesian product)

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

Question 8medium

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
SELECT a.stu_id AS s1, b.stu_id AS s2 FROM student a JOIN student b ON a.dept_name = b.dept_name AND a.stu_id < b.stu_id;

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.

Question 9hard

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
9

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.

Question 10hard

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
8 rows; (Gizem, NULL) and (NULL, BIO)

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

Question 11easy

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
(b) fails: CHECK (credits BETWEEN 1 AND 6)

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

Question 12hard

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
CREATE TABLE advisor (stu_id TEXT PRIMARY KEY REFERENCES student(stu_id) ON DELETE CASCADE, inst_id TEXT NOT NULL REFERENCES instructor(inst_id));

"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).

Review card

DDL: CREATE / ALTER / DROP TABLE DML: SELECT / INSERT / UPDATE / DELETE Constraints: PRIMARY KEY (unique + NOT NULL), UNIQUE, NOT NULL, CHECK (...), DEFAULT v FOREIGN KEY ... REFERENCES t(col) [ON DELETE CASCADE | SET NULL] DROP TABLE = the table is gone; DELETE FROM t = the rows go, the table stays SELECT DISTINCT A FROM r WHERE P = pi_A(sigma_P(r)) (without DISTINCT duplicates stay) BETWEEN includes both ends. IN ('a','b'). LIKE: % = any string, _ = one character ORDER BY a ASC, b DESC (default ASC) int / int is an integer in SQLite: 95000/12 = 7916. Use 12.0 for decimals FROM r, s with no WHERE = Cartesian product (|r| x |s| rows) NATURAL JOIN equates ALL columns with the same name (hidden dept_name trap with 3 tables) LEFT JOIN + WHERE s.col IS NULL = the unmatched ones (condition goes in WHERE, not ON) FULL OUTER: SQLite 3.39+, not in MySQL (LEFT JOIN UNION ...) self join: two aliases, for pairs use a.id < b.id NULL = NULL is not true; in a join NULL matches nothing