Relational Databases, Normalisation and SQL
Why a flat file goes wrong, normalisation to third normal form worked through on real data, then joins, grouping and constraints in SQL that runs, and transactions with ACID, with a failed transfer rolled back in front of you.
Flat files, keys and relationships
A flat file keeps everything in one table. It is easy to start with and goes wrong in a predictable way: facts get repeated.
| OrderID | Customer | City | Product | Price | Quantity |
|---|---|---|---|---|---|
| 1 | Asha Khan | Leeds | Keyboard | 25.00 | 1 |
| 1 | Asha Khan | Leeds | Mouse | 12.50 | 2 |
| 2 | Ben Ali | York | Keyboard | 25.00 | 3 |
| 3 | Asha Khan | Leeds | Monitor | 120.00 | 1 |
Asha's city is stored three times and the price of a keyboard twice. Every repeat is a chance for the copies to disagree:
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE Orders (OrderID INTEGER, Customer TEXT, City TEXT, Product TEXT, Price REAL, Quantity INTEGER)")
db.executemany("INSERT INTO Orders VALUES (?, ?, ?, ?, ?, ?)", [
(1, "Asha Khan", "Leeds", "Keyboard", 25.0, 1),
(1, "Asha Khan", "Leeds", "Mouse", 12.5, 2),
(2, "Ben Ali", "York", "Keyboard", 25.0, 3),
(3, "Asha Khan", "Leeds", "Monitor", 120.0, 1),
])
# Asha moves to Bradford, but the update only reaches one of her three rows
db.execute("UPDATE Orders SET City = 'Bradford' WHERE OrderID = 1 AND Product = 'Keyboard'")
for row in db.execute("SELECT DISTINCT Customer, City FROM Orders ORDER BY Customer, City"):
print(row)The database now says that Asha lives in Bradford and in Leeds. Three kinds of fault come from the repetition:
| Anomaly | What goes wrong | Example |
|---|---|---|
| Update | Changing a fact in some places and not others | Asha's city |
| Insertion | You cannot record a fact without an unrelated one | A new product cannot be added until somebody orders it |
| Deletion | Deleting one fact destroys another | Deleting order 3 removes the only record of the monitor and its price |
The relational answer
A relational database splits the data into several tables, each about one thing, and links them with keys.
| Term | Meaning |
|---|---|
| Entity | A thing the database holds facts about, such as a customer |
| Attribute | One fact about an entity, which is a column |
| Record (tuple) | One entity's row |
| Primary key | Attribute(s) whose value is unique for each record |
| Composite key | A primary key made from two or more attributes together |
| Foreign key | An attribute holding the primary key of another table, which makes the link |
| Secondary key | An attribute indexed for searching, though not unique |
| Referential integrity | Every foreign key value matches a primary key that really exists |
Relationships between entities
A one to many relationship, one customer has many orders, is made with a foreign key on the many side. A many to many relationship, an order contains many products and a product is on many orders, cannot be held directly. It is split by a link table that has a foreign key to each side, OrderLine, whose primary key is the pair together.
Normalisation
Normalisation is the process of organising the tables to remove the repetition, and with it the three anomalies. It goes in stages, each one a normal form, and each includes the one before.
Step 1 of 3 First normal form: one value in every cell, no repeating groups, and a primary key of OrderID with ProductID.
| Form | The rule | What is removed |
|---|---|---|
| 1NF | Every cell holds one value, there are no repeating groups, and there is a primary key | Lists inside a cell, repeated columns such as Product1, Product2 |
| 2NF | In 1NF, and every non key attribute depends on the whole key | Partial dependencies: facts about only part of a composite key |
| 3NF | In 2NF, and no non key attribute depends on another non key attribute | Transitive dependencies: a fact about a fact |
A phrase that holds all three: every attribute depends on the key, the whole key, and nothing but the key.
Worked example
The flat table above has no repeating groups, once the primary key is the pair OrderID and Product, so it is in 1NF. Now work out what each attribute really depends on.
Quantitydepends on both parts of the key: how many of this product on this order.Pricedepends only onProduct. That is a partial dependency.CustomerandCitydepend only onOrderID. That is another.
To reach 2NF, move those out into tables of their own. Now the order's facts and the product's facts are each held once.
Citydepends onCustomer, andCustomerdepends onOrderID. A fact about a fact is a transitive dependency.
To reach 3NF, give the customer a table of their own, and let the order refer to it with a foreign key. The finished design is four tables:
| Table | Columns | Key |
|---|---|---|
Customer | CustomerID, Name, City | CustomerID |
Product | ProductID, Name, Price | ProductID |
CustomerOrder | OrderID, CustomerID | OrderID, with CustomerID as a foreign key |
OrderLine | OrderID, ProductID, Quantity | OrderID and ProductID together, both foreign keys |
Asha's city is stored once. Changing it is one update, and every order sees it.
Building it in SQL
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("PRAGMA foreign_keys = ON")
db.executescript('''
CREATE TABLE Customer (CustomerID INTEGER PRIMARY KEY, Name TEXT NOT NULL, City TEXT);
CREATE TABLE Product (ProductID INTEGER PRIMARY KEY, Name TEXT NOT NULL, Price REAL CHECK (Price > 0));
CREATE TABLE CustomerOrder (
OrderID INTEGER PRIMARY KEY,
CustomerID INTEGER NOT NULL REFERENCES Customer(CustomerID));
CREATE TABLE OrderLine (
OrderID INTEGER REFERENCES CustomerOrder(OrderID),
ProductID INTEGER REFERENCES Product(ProductID),
Quantity INTEGER NOT NULL,
PRIMARY KEY (OrderID, ProductID));
''')
db.executemany("INSERT INTO Customer VALUES (?, ?, ?)", [(1, "Asha Khan", "Leeds"), (2, "Ben Ali", "York")])
db.executemany("INSERT INTO Product VALUES (?, ?, ?)", [(1, "Keyboard", 25.0), (2, "Mouse", 12.5), (3, "Monitor", 120.0)])
db.executemany("INSERT INTO CustomerOrder VALUES (?, ?)", [(1, 1), (2, 2), (3, 1)])
db.executemany("INSERT INTO OrderLine VALUES (?, ?, ?)", [(1, 1, 1), (1, 2, 2), (2, 1, 3), (3, 3, 1)])
db.execute("UPDATE Customer SET City = 'Bradford' WHERE CustomerID = 1") # one change
for row in db.execute("SELECT CustomerID, Name, City FROM Customer"):
print(row)
# referential integrity: an order for a customer that does not exist is refused
try:
db.execute("INSERT INTO CustomerOrder VALUES (4, 99)")
except sqlite3.IntegrityError as error:
print("refused:", error)
# a CHECK constraint refuses a price that makes no sense
try:
db.execute("INSERT INTO Product VALUES (4, 'Faulty', -5)")
except sqlite3.IntegrityError as error:
print("refused a negative price:", type(error).__name__)The constraints do the work that the programmer would otherwise have to remember: PRIMARY KEY and NOT NULL for entity integrity, REFERENCES for referential integrity, and CHECK for domain rules.
SQL at A Level: joins, grouping and aggregates
With the data in separate tables, a query puts it back together with a JOIN. JOIN ... ON pairs rows of two tables where the key matches.
The queries below all run against the four table design. The setup is repeated so that the program runs on its own.
import sqlite3
db = sqlite3.connect(":memory:")
db.executescript('''
CREATE TABLE Customer (CustomerID INTEGER PRIMARY KEY, Name TEXT, City TEXT);
CREATE TABLE Product (ProductID INTEGER PRIMARY KEY, Name TEXT, Price REAL);
CREATE TABLE CustomerOrder (OrderID INTEGER PRIMARY KEY, CustomerID INTEGER);
CREATE TABLE OrderLine (OrderID INTEGER, ProductID INTEGER, Quantity INTEGER);
INSERT INTO Customer VALUES (1, 'Asha Khan', 'Leeds'), (2, 'Ben Ali', 'York');
INSERT INTO Product VALUES (1, 'Keyboard', 25.0), (2, 'Mouse', 12.5), (3, 'Monitor', 120.0);
INSERT INTO CustomerOrder VALUES (1, 1), (2, 2), (3, 1);
INSERT INTO OrderLine VALUES (1, 1, 1), (1, 2, 2), (2, 1, 3), (3, 3, 1);
''')
def run(sql):
print(" ".join(sql.split()))
for row in db.execute(sql):
print(" ", row)
run('''SELECT Customer.Name, Product.Name, OrderLine.Quantity
FROM OrderLine
JOIN CustomerOrder ON OrderLine.OrderID = CustomerOrder.OrderID
JOIN Customer ON CustomerOrder.CustomerID = Customer.CustomerID
JOIN Product ON OrderLine.ProductID = Product.ProductID
WHERE Customer.City = 'Leeds'
ORDER BY Product.Name''')
run('''SELECT CustomerOrder.OrderID, SUM(Product.Price * OrderLine.Quantity) AS Total
FROM CustomerOrder
JOIN OrderLine ON CustomerOrder.OrderID = OrderLine.OrderID
JOIN Product ON OrderLine.ProductID = Product.ProductID
GROUP BY CustomerOrder.OrderID''')
run('''SELECT Customer.Name, SUM(Product.Price * OrderLine.Quantity)
FROM Customer
JOIN CustomerOrder ON Customer.CustomerID = CustomerOrder.CustomerID
JOIN OrderLine ON CustomerOrder.OrderID = OrderLine.OrderID
JOIN Product ON OrderLine.ProductID = Product.ProductID
GROUP BY Customer.CustomerID
HAVING SUM(Product.Price * OrderLine.Quantity) > 100''')
run("SELECT COUNT(*), AVG(Price), MIN(Price), MAX(Price) FROM Product")
run("SELECT Name FROM Product WHERE Price BETWEEN 10 AND 30 AND Name LIKE '%o%'")
run("SELECT Name FROM Customer WHERE CustomerID IN (SELECT CustomerID FROM CustomerOrder WHERE OrderID = 2)")| Clause | Does |
|---|---|
JOIN ... ON a = b | Combines rows from two tables where the keys match |
GROUP BY | Collapses rows that share a value into one row per group |
COUNT, SUM, AVG, MIN, MAX | Aggregate functions: one answer per group |
HAVING | Filters groups, after the aggregates are worked out |
WHERE | Filters rows, before grouping |
BETWEEN, IN, LIKE | Range, a list of values, and a pattern |
A SELECT inside another | A subquery |
WHERE Price > 20 removes products before anything is totalled. HAVING SUM(...) > 100 removes customers after their totals are worked out, which a WHERE cannot do because the totals do not exist yet.
Changing data safely
UPDATE Product SET Price = Price * 1.1 WHERE Name = 'Keyboard'
DELETE FROM OrderLine WHERE OrderID = 3
INSERT INTO Product (ProductID, Name, Price) VALUES (4, 'Webcam', 39.99)Always use parameters, never joined text, when a value comes from a user. You saw this at GCSE as a defence against SQL injection, and it is still the rule: db.execute("SELECT ... WHERE Name = ?", (name,)).
Transactions and ACID
Moving £30 from one account to another takes two updates: take it from one, add it to the other. If the program stops between the two, £30 has disappeared. A transaction is a group of operations treated as one unit, that either all happen or none do.
The four properties a database guarantees are ACID:
| Property | Means | In the transfer |
|---|---|---|
| Atomicity | All of the transaction, or none of it | Both updates happen, or neither does |
| Consistency | The database goes from one valid state to another | The money in the system is the same before and after |
| Isolation | Transactions running at once do not interfere | Another transfer cannot see the half done one |
| Durability | Once committed, the change survives a crash | A power cut after the commit does not lose it |
commit() makes the transaction permanent. rollback() undoes everything since it began.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE Account (ID INTEGER PRIMARY KEY, Owner TEXT, Balance REAL CHECK (Balance >= 0))")
db.executemany("INSERT INTO Account VALUES (?, ?, ?)", [(1, "Asha", 100.0), (2, "Ben", 50.0)])
db.commit()
def balances():
return [row[0] for row in db.execute("SELECT Balance FROM Account ORDER BY ID")]
def transfer(source, target, amount):
try:
db.execute("UPDATE Account SET Balance = Balance + ? WHERE ID = ?", (amount, target))
db.execute("UPDATE Account SET Balance = Balance - ? WHERE ID = ?", (amount, source))
db.commit()
return "committed"
except sqlite3.IntegrityError:
db.rollback()
return "rolled back"
print(transfer(1, 2, 30), balances())
print(transfer(1, 2, 500), balances())In the second transfer, the first statement, crediting Ben with £500, succeeded. The second statement, taking £500 from Asha, was refused by the CHECK, because she only has £70. Without a transaction Ben would now hold £550 that came from nowhere. The rollback undid the credit, and the balances are as they were.
Concurrency and locking
When two users change the same record at once, one change can overwrite the other. The database prevents it with record locking: the first transaction to touch a record locks it, and others wait. Two transactions each waiting for a lock that the other holds are in deadlock, and the database resolves it by rolling one back.
Indexes
An index is a separate sorted structure on a column, usually a tree, which lets the database find rows by that column without reading the whole table. It makes reads faster and writes slower, because every insert must update the index, and it costs storage. Primary keys are indexed automatically, and you add an index to a column you search often.
import sqlite3
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE Person (ID INTEGER PRIMARY KEY, Surname TEXT)")
db.executemany("INSERT INTO Person (Surname) VALUES (?)", [("Khan",), ("Ali",), ("Smith",), ("Khan",)])
db.execute("CREATE INDEX idx_surname ON Person (Surname)")
print(db.execute("SELECT COUNT(*) FROM Person WHERE Surname = 'Khan'").fetchone()[0])- Atomicity: a transaction is all or nothing.
- Consistency: the database goes from one valid state to another, and the rules always hold.
- Isolation: concurrent transactions do not see each other's half finished work.
- Durability: committed data survives a crash.
Key terms you must be able to define
- Flat file
- A database held as a single table, which tends to repeat data.
- Relational database
- A database of several tables, each about one entity, linked by keys.
- Primary key
- An attribute, or set of attributes, whose value is unique for every record in a table.
- Foreign key
- An attribute in one table that holds the primary key of a record in another table.
- Referential integrity
- The rule that every foreign key value must match an existing primary key value.
- Normalisation
- Organising the tables of a database to remove repeated data and the anomalies it causes.
- Partial dependency
- An attribute that depends on only part of a composite primary key.
- Transitive dependency
- A non key attribute that depends on another non key attribute rather than directly on the key.
- Transaction
- A group of database operations that is treated as a single unit.
- ACID
- The four properties of a reliable transaction: atomicity, consistency, isolation and durability.
- Index
- A data structure on a column that lets the database find records quickly without reading the whole table.
Flashcards
Say the definition out loud before you flip the card. Retrieving it from memory is what builds recall, reading it again is not.
Reach the top grade on this topic
Database questions are about structure first and syntax second, so get the design right before writing a query.
- Name the key of every table, and mark each foreign key and what it refers to
- For normalisation, say which rule each change satisfies, and which dependency was removed
- Join tables on matching keys, and put the aggregate condition in HAVING, not in WHERE
- Define each part of ACID in a sentence, and tie it to the example in the question
- Describe an index as a trade: faster searching, slower writing and more storage
Mistakes that cost marks here
- Putting a condition on an aggregate, such as SUM(...) > 100, in a WHERE clause.
- Describing normalisation as making a database smaller, instead of removing repeated data and anomalies.
- Forgetting the foreign key that links two tables, so the design cannot be joined.
- Mixing up a partial dependency, on part of a composite key, with a transitive one, through another attribute.
- Saying atomicity means a transaction is fast, when it means all or nothing.
The relational model was published in 1970 by Edgar Codd, an IBM researcher, in a paper of just thirteen pages. IBM was slow to build it, because it already sold a profitable older database, and the first commercial relational database came from a small start up that became Oracle.
Knowledge check
Exam-style questions
Five written questions in the style of the real paper. Write a full answer, then let the marker check it against the mark scheme.
- LData is duplicated, which wastes storage space
- LThe same data may be stored differently in different places, so it is inconsistent
- LA change to a repeated item must be made in many places, which is slow or may be missed
- LIt is difficult to add or remove data without affecting other data
Data is repeated in many records, which wastes storage space. The repeated data can become inconsistent, if an item is changed in one record but not in the others. A change to repeated data has to be made in every place it appears, which is slow and easy to get wrong. Deleting a record can also lose other facts that were held only in that record.
| OrderID | ProductID | OrderDate | CustomerID | CustomerName | Address | ProductName | Price | Quantity |
|---|---|---|---|---|---|---|---|---|
| 101 | P1 | 03/05 | C7 | A. Khan | 4 High St | Keyboard | 25.00 | 1 |
| 101 | P2 | 03/05 | C7 | A. Khan | 4 High St | Mouse | 12.50 | 2 |
| 102 | P1 | 04/05 | C9 | B. Ali | 9 Mill Rd | Keyboard | 25.00 | 3 |
- LA Customer table with CustomerID as the primary key, holding the name and address
- LA Product table with ProductID as the primary key, holding the name and price
- LAn Order table with OrderID as the primary key, holding the date
- LThe Order table has CustomerID as a foreign key to the Customer table
- LAn OrderLine table, which links orders and products, with Quantity
- LOrderLine has a composite primary key of OrderID and ProductID, which are both foreign keys
Customer details depend only on the customer, so they go in a Customer table: CustomerID (primary key), CustomerName, Address. Product details depend only on the product: Product table with ProductID (primary key), ProductName, Price. Order details depend only on the order: Order table with OrderID (primary key), OrderDate and CustomerID, which is a foreign key to the Customer table. The quantity depends on both the order and the product, so an OrderLine table holds OrderID, ProductID and Quantity, and its primary key is the pair OrderID and ProductID together, each of which is also a foreign key.
- LSELECT the name and SUM of the Total
- LFROM the two tables, joined ON the matching CustomerID
- LGROUP BY the customer
- LHAVING SUM(Total) > 100, not WHERE
SELECT Customer.Name, SUM(CustomerOrder.Total)
FROM Customer
JOIN CustomerOrder ON Customer.CustomerID = CustomerOrder.CustomerID
GROUP BY Customer.CustomerID
HAVING SUM(CustomerOrder.Total) > 100The two tables are joined on the matching CustomerID, the orders are grouped by customer so that SUM gives one total for each, and HAVING keeps only those groups whose total is over 100. WHERE could not be used because the total does not exist until after the grouping.
- LEvery foreign key value must match the primary key of an existing record in the table it refers to
- LThe database refuses an action that would break this rule
- LFor example, an order cannot be added for a customer who does not exist, or a customer cannot be deleted while they still have orders
Referential integrity means that every foreign key value must match the primary key of a record that really exists in the table it refers to. The database refuses any action that would break the rule. For example, it will not allow an order to be recorded for a customer number that is not in the Customer table, and it will not allow a customer to be deleted while orders that refer to them still exist, so there are never orders left pointing at nothing.
- LA transaction treats both updates as a single unit
- LAtomicity means all of the updates happen or none do
- LAfter a failure the first update is rolled back, so no money is lost
- LDurability means that once the transaction is committed, the changes are permanent even if the system then fails
A transaction treats the two updates as a single unit. Atomicity means that either both updates are carried out or neither is, so if the system fails after the first update the transaction is rolled back and the first update is undone, which means no money is lost or created. Durability means that once the transaction has been committed the changes are permanent, and they will still be there even if the system fails immediately afterwards.