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

  1. What SQL is and why it is declarative
  2. The standard and why dialects exist
  3. The five sublanguages: DDL, DML, DQL, DCL and TCL
  4. Writing rules: identifiers, capitalization, quotes and comments
  5. The data types we need right now
  6. CREATE TABLE and the basic constraints
  7. Auto-incrementing keys in PostgreSQL and in SQLite
  8. Deliverable: the complete BiblioRed schema
  9. ALTER TABLE: modifying what already exists
  10. DROP TABLE and the order of destruction
  11. Common mistakes and tips
  12. Exercises
  13. Conclusion

  1. 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 file

You 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 FROM to "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 EXPLAIN in 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.

  1. 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 coreSELECT, 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.

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

  1. 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 forever

Firm 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';   -- Correct

SQLite 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:

SELECT 'O''Brien';   -- returns: O'Brien

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;

  1. 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:

SELECT 0.1::REAL + 0.2::REAL;     -- 0.30000001
SELECT 0.1::NUMERIC + 0.2::NUMERIC;  -- 0.3

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.

  1. CREATE TABLE and the basic constraints

The 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:

ERROR:  insert or update on table "copies" violates foreign key constraint "fk_copies_book"

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

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

  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:

$ psql -U student -d biblioredb
biblioredb=>

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:

CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE

Verification

biblioredb=> \dt
              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:

biblioredb=> \d copies
                              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;
1

PRAGMA foreign_keys = ON is so important that lesson 02-06 comes back to it.

  1. ALTER TABLE: modifying what already exists

Schemas change. ALTER TABLE modifies the structure without losing the data.

Adding a column

BiblioRed wants to record members' mobile phone numbers:

ALTER TABLE members ADD COLUMN phone VARCHAR(20);
ALTER TABLE

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.

ALTER TABLE members ADD COLUMN national_id VARCHAR(9) NOT NULL;
ERROR:  column "national_id" of relation "members" contains null values

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 was

Changing 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

ALTER TABLE members DROP COLUMN mobile_phone;

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.

  1. DROP TABLE and the order of destruction

DROP TABLE reservations;

Removes 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:

DROP TABLE books;
ERROR:  cannot drop table books because other objects depend on it
DETAIL:  constraint fk_copies_book on table copies depends on table books

This 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 called available. Values go between single quotes, always.
  • Forgetting the semicolon. If psql shows biblioredb-# instead of biblioredb=#, 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 books fails if books does not exist yet. Follow the dependency tree.
  • Using FLOAT or REAL for 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 = ON in 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 NULL column 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 with psql -U student -d biblioredb -f biblioredb_schema.sql or with .read biblioredb_schema.sql in 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:

  1. SELECT title FROM books;
  2. ALTER TABLE members ADD COLUMN phone VARCHAR(20);
  3. UPDATE copies SET status = 'available' WHERE copy_id = 1;
  4. GRANT SELECT ON books TO front_desk;
  5. ROLLBACK;
  6. DROP TABLE reservations;
  7. 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.language column included.

  1. Add to branches an optional contact_email column of up to 120 characters.
  2. Add to books an optional integer page_count column.
  3. The books.publisher column is too short: widen it to 150 characters.
  4. branches.opening_date must become mandatory.
  5. Rename reservations.expiry_date to expiration_date.
  6. Remove from books the language column, 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:

  1. 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 in lowercase_with_underscores.
  2. ID with no PRIMARY KEY. The table would have no primary key: entity integrity would be violated and indistinguishable duplicate rows could be inserted.
  3. ID with no auto-increment. The number would have to be invented by hand on every insertion.
  4. Amount FLOAT. It is money: it must be NUMERIC(6,2).
  5. Date VARCHAR(10). It is a date: it must be DATE. As text it could not be sorted reliably, nor subtracted, nor validated.
  6. Paid VARCHAR(2). It is a yes/no: it must be BOOLEAN. With VARCHAR(2) you would end up with 'Y', 'yes', 'YES', '1' and 'no' all coexisting.
  7. "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.
  8. Column name Member is not descriptive: the schema convention is member_id.
  9. 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_underscores and without double quotes, single quotes for text literals, a semicolon at the end and comments with -- or /* */.
  • The types we needed: INTEGER/SMALLINT for integers, NUMERIC(p, s) for money (never floating point), VARCHAR(n)/TEXT for text, DATE/TIMESTAMP for dates in ISO format and BOOLEAN for yes/no, with SQLite's peculiarities in each case.
  • CREATE TABLE with PRIMARY KEY, NOT NULL, UNIQUE and REFERENCES, in column form or in table form with a proper name; auto-incrementing keys (GENERATED ... AS IDENTITY or SERIAL in PostgreSQL, INTEGER PRIMARY KEY in SQLite); ALTER TABLE to add, rename, retype and drop columns; and DROP TABLE, which demands walking the dependencies in reverse order.
  • And, above all, the complete BiblioRed schema: branches, authors, members, books, copies, loans and reservations, 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.

© Copyright 2026. All rights reserved