In the previous lesson we drew BiblioRed's blueprint: seven relations, their keys and their links. Today we learn the language used to explain that blueprint to a management system, and we use it to actually build it inside biblioredb.
SQL is, by some distance, the longest-lived and most profitable special-purpose language in computing: it was born in the seventies, survived every fashion, and today it is spoken not only by PostgreSQL, MySQL, SQLite or Oracle, but also by analytical engines, cloud warehouses and even query layers on top of NoSQL databases. What you learn here will serve you in all of them.
This lesson is the hinge of the course: it starts by explaining the language and ends with a concrete deliverable, the complete CREATE TABLE script for BiblioRed's seven tables, which you must run before moving on to the next lesson. Keep psql or sqlite3 open while you read.
Contents
- What SQL is and why it is declarative
- The standard and why dialects exist
- The five sublanguages: DDL, DML, DQL, DCL and TCL
- Writing rules: identifiers, capitalization, quotes and comments
- The data types we need right now
CREATE TABLEand the basic constraints- Auto-incrementing keys in PostgreSQL and in SQLite
- Deliverable: the complete BiblioRed schema
ALTER TABLE: modifying what already existsDROP TABLEand the order of destruction- Common mistakes and tips
- Exercises
- Conclusion
- What SQL is and why it is declarative
SQL (Structured Query Language) is the standard language for defining, manipulating and querying relational databases. Its most characteristic trait is that it is declarative: you describe what result you want, not how to obtain it.
Compare. This is how "the open loans at the North branch" would be solved in an imperative language such as Python, working with files:
open the loans file
for each line:
if return_date is empty:
look up the matching copy in the copies file
if its branch is 2:
append the line to the result
close the fileYou have had to decide the order of the loops, which file is scanned first and how the lookup is done. Now in SQL:
SELECT l.loan_id, l.loan_date
FROM loans l
JOIN copies c ON c.copy_id = l.copy_id
WHERE l.return_date IS NULL
AND c.branch_id = 2;There are no loops, no traversal order, no data structures. Only the description of the result. The query optimizer —the component we studied in lesson 01-04— is the one that decides whether to scan loans or copies first, whether to use an index or read the whole table, and with which algorithm to pair up the rows. And it makes that decision afresh every time, with the statistics of the moment: the same query that is resolved one way today may be resolved another, faster way tomorrow, without you changing a single letter.
The three practical consequences of this:
- You write less, and more clearly. A five-line query replaces fifty lines of imperative code.
- You do not hand-optimize what the manager optimizes better. Reordering the tables in the
FROMto "go faster" is, in general, wasted time: the optimizer reorders them on its own. - When something is slow, you diagnose it by looking at the plan, not at the SQL. That is what we will do with
EXPLAINin lesson 06-03.
SQL is neither purely declarative nor purely relational (we already saw that it allows duplicates), but that pragmatic mixture is precisely what made it succeed.
- The standard and why dialects exist
SQL has been standardized by ISO and ANSI since 1986, with successive revisions (SQL-92, SQL:1999, SQL:2003, SQL:2011, SQL:2023) that we saw in lesson 01-03. However, no manager implements the standard exactly, and they all add things of their own. The reasons:
- The standard arrives late: vendors invent a useful feature, it becomes popular and years later it is standardized with different syntax.
- The standard leaves areas optional or undefined (date types, auto-increment, result limits), and each vendor fills the gap its own way.
- Every engine has capabilities of its own that the standard does not contemplate.
The result: a broad common core —SELECT, INSERT, UPDATE, DELETE, CREATE TABLE, JOIN, GROUP BY— that works everywhere, and a periphery specific to each dialect.
| Need | Standard / PostgreSQL | SQLite | MySQL |
|---|---|---|---|
| Auto-incrementing key | GENERATED ALWAYS AS IDENTITY / SERIAL |
INTEGER PRIMARY KEY AUTOINCREMENT |
AUTO_INCREMENT |
| Concatenate text | 'a' || 'b' |
'a' || 'b' |
CONCAT('a','b') |
| Limit rows | LIMIT 10 (FETCH FIRST 10 ROWS ONLY is the standard) |
LIMIT 10 |
LIMIT 10 |
| Current date | CURRENT_DATE |
DATE('now') |
CURDATE() |
| Case-insensitive text comparison | ILIKE |
LIKE (already insensitive in ASCII) |
LIKE |
In this course we use PostgreSQL as the reference and flag SQLite's differences where they matter. A permanent piece of advice: write SQL as standard as you can and use dialect extensions only when they bring something real; your SQL will be easier to port and, above all, easier for someone else to understand.
- The five sublanguages: DDL, DML, DQL, DCL and TCL
Although people speak of "SQL" in the singular, its statements are grouped by function. Knowing the groups helps you find your bearings and know which lesson of the course covers what.
| Sublanguage | Full name | What it is for | Main statements | Where it appears in the course |
|---|---|---|---|---|
| DDL | Data Definition Language | Define and modify the structure | CREATE, ALTER, DROP, TRUNCATE, RENAME |
This lesson and 04-04 |
| DML | Data Manipulation Language | Change the data | INSERT, UPDATE, DELETE, MERGE |
02-03 |
| DQL | Data Query Language | Query the data | SELECT |
02-03, 02-04, 02-05 |
| DCL | Data Control Language | Manage permissions | GRANT, REVOKE |
06-04 |
| TCL | Transaction Control Language | Delimit transactions | COMMIT, ROLLBACK, SAVEPOINT |
06-01 |
Two nuances worth having clear from the start:
- Many people consider DQL part of DML, which is why you will sometimes see "four sublanguages". The count does not matter; what matters is the function.
- In PostgreSQL, DDL is transactional: you can create tables inside a transaction and undo it with
ROLLBACK. In many other managers (MySQL with InnoDB, Oracle) a DDL statement implicitly commits whatever was pending. It is a difference with real consequences when writing migration scripts.
Today we work exclusively with DDL.
- Writing rules: identifiers, capitalization, quotes and comments
Keywords and identifiers
- Keywords (
SELECT,FROM,CREATE TABLE) are part of the language. - Identifiers are the names you choose: tables, columns, constraints, indexes.
SQL is case-insensitive for keywords: select, SELECT and SeLeCt are equivalent. The universal convention is to write keywords in UPPERCASE and identifiers in lowercase, because it makes the query readable at a glance:
-- Readable
SELECT title, isbn FROM books WHERE publication_year > 2010;
-- Legal, but hard work to read
select TITLE, ISBN from BOOKS where PUBLICATION_YEAR > 2010;The capitalization trap in identifiers
PostgreSQL, following the standard, folds unquoted identifiers to lowercase. So JoinDate, joindate and JOINDATE are the same column. But if you write the identifier between double quotes, it is respected literally, and from then on you will always have to quote it:
CREATE TABLE test ("JoinDate" DATE);
SELECT JoinDate FROM test; -- ERROR: column "joindate" does not exist
SELECT "JoinDate" FROM test; -- Correct, but condemns you to quotes foreverFirm advice: do not use double quotes in identifiers. Name everything in lowercase_with_underscores, with no accents, no ñ and no spaces. It is the PostgreSQL convention and it saves you an entire category of problems. In this course, every BiblioRed name follows that rule.
Single quotes versus double quotes
This distinction is a constant source of errors for anyone coming from other languages:
| Mark | Meaning in SQL | Example |
|---|---|---|
'single quote' |
Text literal (a value) | WHERE status = 'available' |
"double quote" |
Identifier (a name) | SELECT "JoinDate" |
SELECT * FROM copies WHERE status = "available";
-- ERROR in PostgreSQL: column "available" does not exist
-- PostgreSQL looks for a COLUMN called available, not a piece of text
SELECT * FROM copies WHERE status = 'available'; -- CorrectSQLite is more permissive and in some cases accepts double quotes as text; do not get used to it, because that SQL will not work in PostgreSQL.
To include an apostrophe inside a literal, you double it:
Semicolon
The ; ends a statement. In psql and in sqlite3 it is mandatory: without it, the client understands that the statement continues on the next line and sits there waiting. If you see a prompt like biblioredb-# instead of biblioredb=#, that is exactly it: you are missing the semicolon.
Comments
-- Single-line comment: from the two dashes to the end
/* Comment
spanning several lines.
Useful for documenting a complete script. */
SELECT title -- you can also comment at the end of a line
FROM books;Recommended style
Write multi-line queries with one clause per line. It costs the same and reads infinitely better:
SELECT m.first_name,
m.last_name,
m.email
FROM members m
WHERE m.active = TRUE
AND m.branch_id = 2
ORDER BY m.last_name;
- The data types we need right now
Every column has a type, and that type is the implementation of the domain integrity rule from the previous lesson. Here we see only what is needed to create BiblioRed's tables; the full catalog and the fine criteria for choosing are lesson 04-04.
Integers
| Type | Approximate range | Typical use |
|---|---|---|
SMALLINT |
±32,000 | Years, small counters |
INTEGER (INT) |
±2.1 billion | The default choice for keys and identifiers |
BIGINT |
±9.2·10¹⁸ | Enormous tables, global identifiers |
In SQLite there is a single integer type, INTEGER, which holds up to 8 bytes; the names SMALLINT or BIGINT are accepted but end up being the same thing.
Decimal numbers: the money rule
Here is a decision that separates professionals from amateurs.
| Type | How it stores | Accuracy |
|---|---|---|
NUMERIC(p, s) / DECIMAL(p, s) |
Decimal, with p total digits and s decimal places |
Exact |
REAL, DOUBLE PRECISION, FLOAT |
Binary floating point (IEEE 754) | Approximate |
Floating point cannot represent values such as 0.10 exactly in binary, just as in decimal we cannot write 1/3 with a finite number of digits. That produces results like this:
In BiblioRed the surcharge column stores euros. With floating point, adding up ten thousand surcharges of €0.10 can give 999.9998 instead of 1000.00, and the library's annual accounts will never balance.
Rule: for money, always NUMERIC(p, s). NUMERIC(6, 2) allows up to 9999.99, more than enough for a library surcharge. Save floating point for scientific magnitudes where approximate precision is acceptable (temperatures, coordinates, physical measurements).
SQLite warning: it has no exact decimal type. NUMERIC(6,2) is accepted, but internally it may be stored as floating point. For practice it makes no difference; in production with money, it is one more argument in favor of PostgreSQL.
Text
| Type | Description |
|---|---|
VARCHAR(n) |
Variable-length string with a maximum of n characters |
CHAR(n) |
Fixed length; padded with spaces. Almost never what you want |
TEXT |
String with no declared limit |
In PostgreSQL, VARCHAR(n) and TEXT have the same performance: VARCHAR(n) is not faster, it merely adds a length check. So the criterion is semantic: use VARCHAR(n) when the limit is a real rule (an ISBN has 13 characters, not one more) and TEXT when there is no natural limit (a review, some notes).
Dates and times
| Type | Stores | Example |
|---|---|---|
DATE |
Date only | 2026-07-14 |
TIME |
Time only | 18:30:00 |
TIMESTAMP |
Date and time | 2026-07-14 18:30:00 |
TIMESTAMP WITH TIME ZONE |
Date, time and time zone | 2026-07-14 18:30:00+02 |
The universal format is ISO 8601: YYYY-MM-DD. Always use it and you will avoid the eternal ambiguity of 03/04/2026 (3 April or 4 March?).
In BiblioRed, loan and return dates are DATE: the library cares about the day, not the exact time. If in the future surcharges by the hour were wanted, it would have to migrate to TIMESTAMP.
SQLite has no date type: it stores dates as text '2026-07-14', as a Julian day number or as a Unix integer. Declaring DATE is legal and works as documentation, but SQLite will not validate that the content is a real date. It is a fundamental difference: PostgreSQL uses strict typing and SQLite, type affinity.
Booleans
BOOLEAN accepts TRUE, FALSE and NULL. In BiblioRed, members.active indicates whether the library card is still valid.
SQLite has no BOOLEAN: it uses integers 0 and 1. It accepts the word BOOLEAN in the CREATE TABLE and recognizes TRUE/FALSE from version 3.23, storing them as 1 and 0.
CREATE TABLE and the basic constraints
CREATE TABLE and the basic constraintsThe statement that creates a relation:
CREATE TABLE table_name (
column1 TYPE [column constraints],
column2 TYPE [column constraints],
...
[table constraints]
);The four constraints we will use today:
| Constraint | What it guarantees | Integrity rule involved |
|---|---|---|
PRIMARY KEY |
Unique, non-null value; identifies the row | Entity integrity |
NOT NULL |
The column is never left empty | Domain integrity |
UNIQUE |
No two rows have the same value | Alternate key |
REFERENCES other_table(col) |
The value exists in the referenced table | Referential integrity |
There are two more, CHECK and DEFAULT, studied in depth in 04-04; we do not use them today so as not to get ahead of design decisions.
A commented example with BiblioRed's simplest table:
CREATE TABLE branches (
branch_id INTEGER PRIMARY KEY, -- primary key: unique and implicitly NOT NULL
name VARCHAR(60) NOT NULL UNIQUE, -- mandatory and non-repeating
address VARCHAR(120) NOT NULL,
phone VARCHAR(20), -- accepts NULL: it may be unknown
opening_date DATE
);An important point: phone is VARCHAR, not a number. Phone numbers are not added up or averaged, they can start with a zero and they carry spaces or the +34 prefix. The general rule is: if you are not going to do arithmetic with it, it is not a number. The same applies to ISBNs and postal codes.
Column constraints versus table constraints
Constraints can be written next to the column or at the end, as a standalone element. The following two forms are equivalent:
-- Column form (compact)
CREATE TABLE example_a (
book_id INTEGER NOT NULL REFERENCES books(book_id)
);
-- Table form, with a proper name for the constraint
CREATE TABLE example_b (
book_id INTEGER NOT NULL,
CONSTRAINT fk_example_book FOREIGN KEY (book_id) REFERENCES books(book_id)
);The table form is mandatory when the constraint affects several columns (a composite primary key, for example) and advisable when you want to give it a readable name. Why does the name matter? Because when the constraint is violated, the error message will mention it:
A self-explanatory name turns a cryptic error into an immediate diagnosis. The convention we will follow: fk_<table>_<reference>, uq_<table>_<column>, pk_<table>.
- Auto-incrementing keys in PostgreSQL and in SQLite
In the previous lesson we decided to use surrogate primary keys. Somebody has to generate those numbers, and we do not want to do it by hand. Each manager solves it its own way.
PostgreSQL
-- Modern form, SQL:2003 standard. This is the recommended one.
CREATE TABLE branches (
branch_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(60) NOT NULL UNIQUE
);
-- Classic PostgreSQL form, equivalent and still very common.
CREATE TABLE branches (
branch_id SERIAL PRIMARY KEY,
name VARCHAR(60) NOT NULL UNIQUE
);Differences between the two:
| Aspect | SERIAL |
GENERATED ... AS IDENTITY |
|---|---|---|
| Origin | PostgreSQL's own extension | SQL standard |
| Mechanism | Creates a sequence and gives it a DEFAULT |
Sequence managed internally |
| Does it allow inserting a manual value? | Yes, always | ALWAYS: no, except with OVERRIDING SYSTEM VALUE. BY DEFAULT: yes |
| When the table is dropped | The sequence is linked and gets dropped | Dropped with the table |
For BiblioRed we will use GENERATED BY DEFAULT AS IDENTITY: it is standard and, on top of that, it lets us insert explicit identifiers, which is exactly what we will need in the next lesson to load the data set with the member_id and book_id values we already have fixed (14, 15, 16, 331…).
SQLite
CREATE TABLE branches (
branch_id INTEGER PRIMARY KEY, -- auto-increments all by itself
name TEXT NOT NULL UNIQUE
);In SQLite, a column declared exactly as INTEGER PRIMARY KEY is an alias for the internal rowid and auto-increments automatically if you give it no value. The word AUTOINCREMENT is optional and only adds the guarantee that a deleted identifier is never reused, at the cost of an extra internal table. SQLite's official documentation recommends not using it unless that guarantee is needed.
Watch the detail: it has to be INTEGER, in upper or lower case but that exact word. INT PRIMARY KEY does not activate the behavior.
Summary table
| PostgreSQL | SQLite | |
|---|---|---|
| Recommended | INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY |
INTEGER PRIMARY KEY |
| Alternative | SERIAL PRIMARY KEY |
INTEGER PRIMARY KEY AUTOINCREMENT |
| Starts at | 1 | 1 (or the existing maximum + 1) |
- Deliverable: the complete BiblioRed schema
The moment has come. This is the script that turns lesson 02-01's blueprint into real tables.
Order matters
A table cannot reference another that does not exist yet. They have to be created following the dependencies:
flowchart TD
A["1. branches<br/><i>no dependencies</i>"] --> B["2. members<br/><i>-> branches</i>"]
C["1. authors<br/><i>no dependencies</i>"] --> D["3. books<br/><i>-> authors</i>"]
A --> E["4. copies<br/><i>-> books, branches</i>"]
D --> E
B --> F["5. loans<br/><i>-> members, copies</i>"]
E --> F
B --> G["6. reservations<br/><i>-> members, books</i>"]
D --> G
One valid order: branches, authors, members, books, copies, loans, reservations.
The script (PostgreSQL)
First connect to the database you created in lesson 01-04:
And run:
-- ============================================================
-- BiblioRed - Relational schema
-- Module 2, lesson 02-02. Dialect: PostgreSQL
-- ============================================================
-- 1. BRANCHES: the network's four libraries.
-- No foreign keys: it is the root of the schema.
CREATE TABLE branches (
branch_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
name VARCHAR(60) NOT NULL,
address VARCHAR(120) NOT NULL,
phone VARCHAR(20),
opening_date DATE,
CONSTRAINT pk_branches PRIMARY KEY (branch_id),
CONSTRAINT uq_branches_name UNIQUE (name)
);
-- 2. AUTHORS: the author catalog. It does not depend on anyone either.
-- birth_year is SMALLINT: a year fits easily.
CREATE TABLE authors (
author_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
first_name VARCHAR(60) NOT NULL,
last_name VARCHAR(80) NOT NULL,
nationality VARCHAR(40),
birth_year SMALLINT,
CONSTRAINT pk_authors PRIMARY KEY (author_id)
);
-- 3. MEMBERS: the people holding a library card.
-- email is UNIQUE but accepts NULL: not everyone gives an address,
-- and in SQL several NULLs are not considered duplicates of one another.
-- branch_id is NOT NULL: every member signs up at a branch.
CREATE TABLE members (
member_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
first_name VARCHAR(60) NOT NULL,
last_name VARCHAR(80) NOT NULL,
email VARCHAR(120),
join_date DATE NOT NULL,
branch_id INTEGER NOT NULL,
active BOOLEAN NOT NULL,
CONSTRAINT pk_members PRIMARY KEY (member_id),
CONSTRAINT uq_members_email UNIQUE (email),
CONSTRAINT fk_members_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
);
-- 4. BOOKS: the WORK, not the physical object.
-- isbn: alternate key (UNIQUE) and not the primary key, because
-- not every holding has an ISBN. VARCHAR, not a number: no arithmetic on it.
-- author_id accepts NULL: there are anonymous or uncataloged works.
CREATE TABLE books (
book_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
isbn VARCHAR(13),
title VARCHAR(200) NOT NULL,
author_id INTEGER,
publisher VARCHAR(80),
publication_year SMALLINT,
language VARCHAR(20),
CONSTRAINT pk_books PRIMARY KEY (book_id),
CONSTRAINT uq_books_isbn UNIQUE (isbn),
CONSTRAINT fk_books_author
FOREIGN KEY (author_id) REFERENCES authors (author_id)
);
-- 5. COPIES: the physical object that gets lent.
-- code is the label stuck on the spine ('EJ-3081'): alternate key.
-- status: 'available', 'on_loan', 'in_repair', 'withdrawn'.
-- (The CHECK constraint that guarantees it is added in lesson 04-04.)
CREATE TABLE copies (
copy_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
code VARCHAR(10) NOT NULL,
book_id INTEGER NOT NULL,
branch_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL,
acquisition_date DATE,
CONSTRAINT pk_copies PRIMARY KEY (copy_id),
CONSTRAINT uq_copies_code UNIQUE (code),
CONSTRAINT fk_copies_book
FOREIGN KEY (book_id) REFERENCES books (book_id),
CONSTRAINT fk_copies_branch
FOREIGN KEY (branch_id) REFERENCES branches (branch_id)
);
-- 6. LOANS: joins a member with a specific COPY.
-- return_date NULL = loan still open.
-- surcharge NUMERIC(6,2): it is money, never floating point.
CREATE TABLE loans (
loan_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
copy_id INTEGER NOT NULL,
loan_date DATE NOT NULL,
due_date DATE NOT NULL,
return_date DATE,
surcharge NUMERIC(6,2),
CONSTRAINT pk_loans PRIMARY KEY (loan_id),
CONSTRAINT fk_loans_member
FOREIGN KEY (member_id) REFERENCES members (member_id),
CONSTRAINT fk_loans_copy
FOREIGN KEY (copy_id) REFERENCES copies (copy_id)
);
-- 7. RESERVATIONS: joins a member with a BOOK (the work), not with a copy:
-- the member reserves the title and gets the first copy that frees up.
-- status: 'active', 'fulfilled', 'cancelled'.
CREATE TABLE reservations (
reservation_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
book_id INTEGER NOT NULL,
reservation_date DATE NOT NULL,
expiry_date DATE,
status VARCHAR(20) NOT NULL,
CONSTRAINT pk_reservations PRIMARY KEY (reservation_id),
CONSTRAINT fk_reservations_member
FOREIGN KEY (member_id) REFERENCES members (member_id),
CONSTRAINT fk_reservations_book
FOREIGN KEY (book_id) REFERENCES books (book_id)
);Expected output, one line per statement:
Verification
List of relations
Schema | Name | Type | Owner
--------+--------------+-------+---------
public | authors | table | student
public | books | table | student
public | branches | table | student
public | copies | table | student
public | loans | table | student
public | members | table | student
public | reservations | table | student
(7 rows)And the detail of one specific table:
Table "public.copies"
Column | Type | Nullable | Default
------------------+-----------------------+----------+------------------------------------
copy_id | integer | not null | generated by default as identity
code | character varying(10) | not null |
book_id | integer | not null |
branch_id | integer | not null |
status | character varying(20) | not null |
acquisition_date | date | |
Indexes:
"pk_copies" PRIMARY KEY, btree (copy_id)
"uq_copies_code" UNIQUE CONSTRAINT, btree (code)
Foreign-key constraints:
"fk_copies_book" FOREIGN KEY (book_id) REFERENCES books(book_id)
"fk_copies_branch" FOREIGN KEY (branch_id) REFERENCES branches(branch_id)If you see this, the schema is standing.
The SQLite version
The same script, with three changes: the auto-incrementing key, TEXT instead of VARCHAR (SQLite treats them the same, but it is its convention) and one indispensable line at the beginning.
-- MANDATORY! SQLite ignores foreign keys unless they are enabled,
-- and it has to be done in EVERY session. Without this, the schema is still
-- created but protects nothing: you will be able to insert loans for members who do not exist.
PRAGMA foreign_keys = ON;
CREATE TABLE branches (
branch_id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
address TEXT NOT NULL,
phone TEXT,
opening_date TEXT -- SQLite has no DATE type
);
CREATE TABLE authors (
author_id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
nationality TEXT,
birth_year INTEGER
);
CREATE TABLE members (
member_id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT UNIQUE,
join_date TEXT NOT NULL,
branch_id INTEGER NOT NULL REFERENCES branches(branch_id),
active INTEGER NOT NULL -- 0 / 1: SQLite has no BOOLEAN
);
CREATE TABLE books (
book_id INTEGER PRIMARY KEY,
isbn TEXT UNIQUE,
title TEXT NOT NULL,
author_id INTEGER REFERENCES authors(author_id),
publisher TEXT,
publication_year INTEGER,
language TEXT
);
CREATE TABLE copies (
copy_id INTEGER PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
book_id INTEGER NOT NULL REFERENCES books(book_id),
branch_id INTEGER NOT NULL REFERENCES branches(branch_id),
status TEXT NOT NULL,
acquisition_date TEXT
);
CREATE TABLE loans (
loan_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id),
copy_id INTEGER NOT NULL REFERENCES copies(copy_id),
loan_date TEXT NOT NULL,
due_date TEXT NOT NULL,
return_date TEXT,
surcharge NUMERIC
);
CREATE TABLE reservations (
reservation_id INTEGER PRIMARY KEY,
member_id INTEGER NOT NULL REFERENCES members(member_id),
book_id INTEGER NOT NULL REFERENCES books(book_id),
reservation_date TEXT NOT NULL,
expiry_date TEXT,
status TEXT NOT NULL
);Verification:
sqlite> .tables
authors books branches copies loans members reservations
sqlite> PRAGMA foreign_keys;
1PRAGMA foreign_keys = ON is so important that lesson 02-06 comes back to it.
ALTER TABLE: modifying what already exists
ALTER TABLE: modifying what already existsSchemas change. ALTER TABLE modifies the structure without losing the data.
Adding a column
BiblioRed wants to record members' mobile phone numbers:
The column is added at the end and every existing row is left with NULL. That is why adding a NOT NULL column to a table with data fails: there is no value to put in the rows that are already there.
The usual solution has three steps: add it nullable, fill it in with an UPDATE (lesson 02-03) and then impose the NOT NULL.
Renaming a column or a table
ALTER TABLE members RENAME COLUMN phone TO mobile_phone;
ALTER TABLE members RENAME TO users; -- rename the whole table
ALTER TABLE users RENAME TO members; -- put it back as it wasChanging a column's type
-- PostgreSQL: the syntax is ALTER COLUMN ... TYPE
ALTER TABLE books ALTER COLUMN publisher TYPE VARCHAR(120);Widening the size is safe. Narrowing it or changing type family can fail if the existing data does not fit or cannot be converted; in that case PostgreSQL aborts and touches nothing.
Adding or removing constraints
ALTER TABLE members ALTER COLUMN email SET NOT NULL;
ALTER TABLE members ALTER COLUMN email DROP NOT NULL;
ALTER TABLE books ADD CONSTRAINT uq_books_isbn UNIQUE (isbn);
ALTER TABLE books DROP CONSTRAINT uq_books_isbn;Here you see the practical payoff of having named the constraints: to remove one you have to name it, and uq_books_isbn is a good deal more manageable than the automatic name books_isbn_key.
Dropping a column
This deletes that column's data and there is no undo (outside a transaction). Think twice.
SQLite's limitation
SQLite supports only a subset of ALTER TABLE:
| Operation | PostgreSQL | SQLite |
|---|---|---|
ADD COLUMN |
Yes | Yes |
RENAME TO (table) |
Yes | Yes |
RENAME COLUMN |
Yes | Yes (since 3.25) |
DROP COLUMN |
Yes | Yes (since 3.35, with restrictions) |
ALTER COLUMN ... TYPE |
Yes | No |
ADD CONSTRAINT |
Yes | No |
SQLite's official procedure for what it does not support is: create a new table with the correct structure, copy the data with INSERT INTO ... SELECT, drop the old one and rename the new one. It is tedious, and it is another argument in favor of PostgreSQL for a schema that is going to evolve.
DROP TABLE and the order of destruction
DROP TABLE and the order of destructionRemoves the table and all its data, immediately and without confirmation. In PostgreSQL, if you run it inside a transaction you can still ROLLBACK (lesson 06-01); in SQLite and in most managers, you cannot.
Two useful variants:
-- Does not fail if the table does not exist: essential in re-runnable scripts
DROP TABLE IF EXISTS reservations;
-- Also removes the objects that depend on it (dangerous!)
DROP TABLE books CASCADE;The reverse of the creation order
If you try to drop a table that others point at, the manager stops you:
ERROR: cannot drop table books because other objects depend on it
DETAIL: constraint fk_copies_book on table copies depends on table booksThis is referential integrity working exactly as it should. To empty the schema and start over, you have to go in the reverse of the creation order: children first, parents afterwards.
DROP TABLE IF EXISTS reservations;
DROP TABLE IF EXISTS loans;
DROP TABLE IF EXISTS copies;
DROP TABLE IF EXISTS books;
DROP TABLE IF EXISTS members;
DROP TABLE IF EXISTS authors;
DROP TABLE IF EXISTS branches;Save these seven lines at the top of your creation script, commented out. When you want to rebuild the schema from scratch —and you will want to several times during the course— you uncomment them and run the whole file.
Common Mistakes and Tips
- Using double quotes for text values.
WHERE status = "available"makes PostgreSQL look for a column calledavailable. Values go between single quotes, always. - Forgetting the semicolon. If
psqlshowsbiblioredb-#instead ofbiblioredb=#, the statement is still open. Type;and press Enter. - Naming columns with capitals or accents.
"JoinDate"or"año"condemn you to quoting in every query you write for the rest of the database's life.join_date,year_. - Creating the tables in the wrong order.
REFERENCES booksfails ifbooksdoes not exist yet. Follow the dependency tree. - Using
FLOATorREALfor money. Sums that do not add up, cents that evaporate, impossible year-end closings.NUMERIC(p, s). - Storing phone numbers, ISBNs or postal codes as numbers. You lose the leading zeros, the prefixes and the hyphens. If you do not do arithmetic with it, it is text.
- Forgetting
PRAGMA foreign_keys = ONin SQLite. The schema is created looking correct but validates nothing, and you discover the orphan rows months later. It has to be run in every session. - Adding a
NOT NULLcolumn to a table with data. It always fails. Add it nullable, fill it in and then impose the constraint. - Tip: save the schema script in a file (
biblioredb_schema.sql) and run it withpsql -U student -d biblioredb -f biblioredb_schema.sqlor with.read biblioredb_schema.sqlin SQLite. A schema that exists only in the console history is a lost schema. - Tip: name every constraint. The day an error fires, the name will be half the diagnosis.
Exercises
Exercise 1: Classifying statements by sublanguage
State which sublanguage each statement belongs to and which lesson of the course covers it:
SELECT title FROM books;ALTER TABLE members ADD COLUMN phone VARCHAR(20);UPDATE copies SET status = 'available' WHERE copy_id = 1;GRANT SELECT ON books TO front_desk;ROLLBACK;DROP TABLE reservations;INSERT INTO authors (first_name, last_name) VALUES ('Marina', 'Escolá');
Exercise 2: Spotting and fixing errors in a CREATE TABLE
BiblioRed wants a new table, fines, for the penalties each member has accumulated. A colleague has written this:
CREATE TABLE Fines (
ID INT,
Member INT NOT NULL REFERENCES members(member_id),
Amount FLOAT NOT NULL,
Date VARCHAR(10) NOT NULL,
Reason VARCHAR(200),
Paid VARCHAR(2) NOT NULL,
"Nº Notice" INT
)Find at least six problems and rewrite the table correctly for PostgreSQL, following the course conventions.
Exercise 3: Evolving the schema
Write the ALTER TABLE statements needed for each change BiblioRed's management asks for. Also state, where relevant, whether the operation can fail on a table that already has data and how to avoid it.
Warning: this exercise is about writing, not running. If you decide to try it out, do it on a separate database, or undo the changes afterwards: the rest of the module assumes the schema exactly as it stood in section 8, with the
books.languagecolumn included.
- Add to
branchesan optionalcontact_emailcolumn of up to 120 characters. - Add to
booksan optional integerpage_countcolumn. - The
books.publishercolumn is too short: widen it to 150 characters. branches.opening_datemust become mandatory.- Rename
reservations.expiry_datetoexpiration_date. - Remove from
booksthelanguagecolumn, which nobody uses.
Solutions
Solution 1
| # | Statement | Sublanguage | Lesson |
|---|---|---|---|
| 1 | SELECT |
DQL (or DML in the four-group classification) | 02-03 |
| 2 | ALTER TABLE |
DDL | 02-02 (this one) |
| 3 | UPDATE |
DML | 02-03 |
| 4 | GRANT |
DCL | 06-04 |
| 5 | ROLLBACK |
TCL | 06-01 |
| 6 | DROP TABLE |
DDL | 02-02 (this one) |
| 7 | INSERT |
DML | 02-03 |
Solution 2
Problems found:
Fines,ID,Member… in mixed case. PostgreSQL folds them to lowercase, so it "works", but it breaks the convention of the rest of the schema. Names inlowercase_with_underscores.IDwith noPRIMARY KEY. The table would have no primary key: entity integrity would be violated and indistinguishable duplicate rows could be inserted.IDwith no auto-increment. The number would have to be invented by hand on every insertion.Amount FLOAT. It is money: it must beNUMERIC(6,2).Date VARCHAR(10). It is a date: it must beDATE. As text it could not be sorted reliably, nor subtracted, nor validated.Paid VARCHAR(2). It is a yes/no: it must beBOOLEAN. WithVARCHAR(2)you would end up with'Y','yes','YES','1'and'no'all coexisting."Nº Notice"in double quotes, with a space and aº. It condemns you to quoting forever and it is neither URL-safe nor portable.notice_number.- Column name
Memberis not descriptive: the schema convention ismember_id. - Unnamed constraints and the final semicolon is missing.
Corrected version:
CREATE TABLE fines (
fine_id INTEGER GENERATED BY DEFAULT AS IDENTITY,
member_id INTEGER NOT NULL,
amount NUMERIC(6,2) NOT NULL,
fine_date DATE NOT NULL,
reason VARCHAR(200),
paid BOOLEAN NOT NULL,
notice_number INTEGER,
CONSTRAINT pk_fines PRIMARY KEY (fine_id),
CONSTRAINT fk_fines_member
FOREIGN KEY (member_id) REFERENCES members (member_id)
);Solution 3
-- 1. Optional column: no risk, the existing rows are left at NULL.
ALTER TABLE branches ADD COLUMN contact_email VARCHAR(120);
-- 2. The same: optional, no risk.
ALTER TABLE books ADD COLUMN page_count INTEGER;
-- 3. Widening a VARCHAR is safe: everything that fitted in 80 fits in 150.
-- (Narrowing it could fail if some value exceeded the new limit.)
ALTER TABLE books ALTER COLUMN publisher TYPE VARCHAR(150);
-- 4. IT CAN FAIL: if some branch has opening_date at NULL, PostgreSQL
-- aborts. You have to fill it in first and then impose the constraint.
UPDATE branches SET opening_date = '2000-01-01' WHERE opening_date IS NULL;
ALTER TABLE branches ALTER COLUMN opening_date SET NOT NULL;
-- 5. Renaming does not touch the data, but it DOES break the queries and
-- applications that used the old name. They have to be reviewed.
ALTER TABLE reservations RENAME COLUMN expiry_date TO expiration_date;
-- 6. Destructive and irreversible outside a transaction: the data is lost.
ALTER TABLE books DROP COLUMN language;A note on SQLite: points 3 and 4 cannot be done with ALTER TABLE; you would have to recreate the table, copy the data and rename.
Conclusion
This lesson has turned the blueprint into a building. Let's review:
- SQL is declarative: you describe the result and the optimizer —the one from lesson 01-04— decides the route. That means you write less and the manager can improve performance without you touching the query.
- There is a standard and there are dialects: a very broad common core and a periphery specific to each engine. PostgreSQL is our reference; SQLite, the lightweight alternative.
- The five sublanguages: DDL (structure), DML (data), DQL (queries), DCL (permissions) and TCL (transactions).
- The writing rules: keywords in uppercase, identifiers in
lowercase_with_underscoresand without double quotes, single quotes for text literals, a semicolon at the end and comments with--or/* */. - The types we needed:
INTEGER/SMALLINTfor integers,NUMERIC(p, s)for money (never floating point),VARCHAR(n)/TEXTfor text,DATE/TIMESTAMPfor dates in ISO format andBOOLEANfor yes/no, with SQLite's peculiarities in each case. CREATE TABLEwithPRIMARY KEY,NOT NULL,UNIQUEandREFERENCES, in column form or in table form with a proper name; auto-incrementing keys (GENERATED ... AS IDENTITYorSERIALin PostgreSQL,INTEGER PRIMARY KEYin SQLite);ALTER TABLEto add, rename, retype and drop columns; andDROP TABLE, which demands walking the dependencies in reverse order.- And, above all, the complete BiblioRed schema:
branches,authors,members,books,copies,loansandreservations, created and verified with\dt.
The seven tables exist and they are empty. In lesson 02-03, Basic SQL Operations, we fill them: you will see INSERT, SELECT with WHERE, ORDER BY and LIMIT, UPDATE and DELETE, all on a single table. That lesson's deliverable will be BiblioRed's test data set —the four branches, the members, the books, the copies and the fictional loans— which we will use until the end of the module. Do not drop your tables: from now on, every lesson builds on the previous one.
Database Fundamentals
Module 1: Introduction to Databases
- Basic Database Concepts
- Types of Databases
- History and Evolution of Databases
- Database Management Systems and Architecture
Module 2: Relational Databases
- The Relational Model
- The SQL Language
- Basic SQL Operations
- Multi-Table Queries: JOINs and Subqueries
- Data Aggregation and Grouping
- Referential Integrity
Module 3: Non-Relational Databases
- Introduction to NoSQL
- Types of NoSQL Databases
- Data Modeling in NoSQL
- Comparing Relational and Non-Relational Databases
Module 4: Schema Design
- Schema Design Principles
- Entity-Relationship (ER) Diagrams
- Transforming ER Diagrams into Relational Schemas
- Data Types and Constraints
Module 5: Normalization
Module 6: Transactions, Performance, and Security
- Transactions and ACID Properties
- Concurrency and Isolation Levels
- Indexes and Query Optimization
- Security, Permissions, and Backups
Module 7: Practical Exercises
- SQL Exercises
- Schema Design Exercises
- Normalization Exercises
- Advanced Query and Transaction Exercises
Module 8: Case Studies
- Case Study: Relational Database
- Case Study: Non-Relational Database
- Case Study: Polyglot Persistence
