Database Management Systems: Normalization and SQL Optimization Lab
Learning Objectives
By the end of this lab, you will be able to:
- Identify repeating groups, partial dependencies and transitive dependencies in a business spreadsheet.
- Normalize a table step by step to 1NF, 2NF and 3NF, and explain what BCNF adds.
- Write
CREATE TABLEstatements with primary keys, foreign keys and constraints, then load sample data. - Answer business questions with JOINs, GROUP BY and subqueries.
- Read SQLite’s EXPLAIN QUERY PLAN output and use indexes, sargable
WHEREclauses, column selection, batching and keyset pagination to make queries faster.
Before You Start
This lab builds on 5.4 Database Management Systems, 5.5 Relational Databases (tables, primary and foreign keys) and 5.6 Introduction to SQL (SELECT, INSERT, UPDATE, DELETE). Those notes explain what a relational database is. This lab is hands-on: you will design one and then make it fast.
Software (free): We use SQLite, a database engine stored in a single file. You can run every statement in any of these:
- Python (already includes SQLite through the built-in
sqlite3module; no installation needed). The outputs in this note were produced with Python 3.11 and SQLite 3.53.1. - The sqlite3 command-line shell from sqlite.org/download.html.
- DB Browser for SQLite, a free graphical tool.
All names, phone numbers, publishers, prices and orders below are hypothetical teaching data. Book titles and authors are real Nepali literature, used only as product names.
Part A: Normalization, Step by Step
A.1 The Starting Point: An Unnormalized Spreadsheet
Pustak Ghar, a small bookshop in Kathmandu, keeps orders in one spreadsheet. Each order is one row, and the books in it are typed into a single cell.
| OrderID | OrderDate | Customer | Phone | Area | Books (Title, Author, Publisher, City, Qty, Price) |
|---|---|---|---|---|---|
| 1001 | 2026-09-01 | Sita Shrestha | 9841000001 | Baneshwor | Muna Madan, Devkota, Sagarmatha Books, Kathmandu, 2, 250; Palpasa Cafe, Wagle, Bagmati Press, Lalitpur, 1, 450 |
| 1002 | 2026-09-01 | Ram Thapa | 9851000002 | Patan | Karnali Blues, Buddhisagar, Bagmati Press, Lalitpur, 1, 550 |
| 1003 | 2026-09-02 | Sita Shrestha | 9841000001 | Baneshwor | Seto Dharti, Neupane, Sagarmatha Books, Kathmandu, 1, 500 |
| 1004 | 2026-09-03 | Anita Gurung | 9801000003 | Thamel | Muna Madan, …, 1, 250; Shirishko Phool, Parijat, Sagarmatha Books, Kathmandu, 1, 350; Karnali Blues, …, 1, 550 |
| 1005 | 2026-09-05 | Bikash Rai | 9861000004 | Baneshwor | Palpasa Cafe, Wagle, Bagmati Press, Lalitpur, 2, 420 |
Problems a manager will hit with this sheet:
- Cannot query easily: “How many copies of Muna Madan did we sell?” requires reading text inside cells.
- Update anomaly: if Sita changes her phone number, it must be changed in every row she appears in. Miss one and the data disagree.
- Insertion anomaly: a new book (say Sumnima by B. P. Koirala) cannot be recorded until someone orders it.
- Deletion anomaly: delete order 1003 (perhaps it was cancelled) and the shop loses its only record that it stocks Seto Dharti at NPR 500.
Normalization is the process of splitting such a table into smaller, related tables so that each fact is stored once. Each “normal form” removes one kind of problem.
A.2 First Normal Form (1NF): One Value per Cell
Rule: every cell holds a single (atomic) value, there are no repeating groups, and each row is uniquely identified by a key.
Fix: give each book in an order its own row.
| OrderID | BookTitle | OrderDate | Customer | Phone | Area | Author | Publisher | PubCity | Qty | UnitPrice |
|---|---|---|---|---|---|---|---|---|---|---|
| 1001 | Muna Madan | 2026-09-01 | Sita Shrestha | 9841000001 | Baneshwor | Laxmi Prasad Devkota | Sagarmatha Books | Kathmandu | 2 | 250 |
| 1001 | Palpasa Cafe | 2026-09-01 | Sita Shrestha | 9841000001 | Baneshwor | Narayan Wagle | Bagmati Press | Lalitpur | 1 | 450 |
| 1002 | Karnali Blues | 2026-09-01 | Ram Thapa | 9851000002 | Patan | Buddhisagar | Bagmati Press | Lalitpur | 1 | 550 |
| 1003 | Seto Dharti | 2026-09-02 | Sita Shrestha | 9841000001 | Baneshwor | Amar Neupane | Sagarmatha Books | Kathmandu | 1 | 500 |
| 1004 | Muna Madan | 2026-09-03 | Anita Gurung | 9801000003 | Thamel | Laxmi Prasad Devkota | Sagarmatha Books | Kathmandu | 1 | 250 |
| 1004 | Shirishko Phool | 2026-09-03 | Anita Gurung | 9801000003 | Thamel | Parijat | Sagarmatha Books | Kathmandu | 1 | 350 |
| 1004 | Karnali Blues | 2026-09-03 | Anita Gurung | 9801000003 | Thamel | Buddhisagar | Bagmati Press | Lalitpur | 1 | 550 |
| 1005 | Palpasa Cafe | 2026-09-05 | Bikash Rai | 9861000004 | Baneshwor | Narayan Wagle | Bagmati Press | Lalitpur | 2 | 420 |
Primary key: the combination (OrderID, BookTitle). In practice titles are not unique, so we will give each book a BookID (a surrogate key). The key becomes (OrderID, BookID).
The table is now in 1NF, but the duplication is worse: Sita’s details appear three times.
A.3 Second Normal Form (2NF): No Partial Dependencies
Rule: the table is in 1NF and every non-key column depends on the whole primary key, not just part of it. (This only matters when the key has two or more columns.)
Ask of each column: “Do I need both OrderID and BookID to know this?”
| Column | Depends on | Type of dependency |
|---|---|---|
| OrderDate, Customer, Phone, Area | OrderID only | Partial |
| Title, Author, Publisher, PubCity | BookID only | Partial |
| Qty, UnitPrice | OrderID and BookID | Full |
Fix: move each partial dependency into its own table.
Orders (key: OrderID)
| OrderID | OrderDate | Customer | Phone | Area |
|---|---|---|---|---|
| 1001 | 2026-09-01 | Sita Shrestha | 9841000001 | Baneshwor |
| 1002 | 2026-09-01 | Ram Thapa | 9851000002 | Patan |
| 1003 | 2026-09-02 | Sita Shrestha | 9841000001 | Baneshwor |
| 1004 | 2026-09-03 | Anita Gurung | 9801000003 | Thamel |
| 1005 | 2026-09-05 | Bikash Rai | 9861000004 | Baneshwor |
Books (key: BookID)
| BookID | Title | Author | Publisher | PubCity | ListPrice |
|---|---|---|---|---|---|
| 1 | Muna Madan | Laxmi Prasad Devkota | Sagarmatha Books | Kathmandu | 250 |
| 2 | Shirishko Phool | Parijat | Sagarmatha Books | Kathmandu | 350 |
| 3 | Palpasa Cafe | Narayan Wagle | Bagmati Press | Lalitpur | 450 |
| 4 | Karnali Blues | Buddhisagar | Bagmati Press | Lalitpur | 550 |
| 5 | Seto Dharti | Amar Neupane | Sagarmatha Books | Kathmandu | 500 |
| 6 | Sumnima | B. P. Koirala | Bagmati Press | Lalitpur | 300 |
OrderLines (key: OrderID + BookID)
| OrderID | BookID | Qty | UnitPrice |
|---|---|---|---|
| 1001 | 1 | 2 | 250 |
| 1001 | 3 | 1 | 450 |
| 1002 | 4 | 1 | 550 |
| 1003 | 5 | 1 | 500 |
| 1004 | 1 | 1 | 250 |
| 1004 | 2 | 1 | 350 |
| 1004 | 4 | 1 | 550 |
| 1005 | 3 | 2 | 420 |
Notice that Sumnima can now be stored before anyone buys it (insertion anomaly fixed).
Design question: Isn’t UnitPrice in OrderLines a duplicate of ListPrice in Books? No. ListPrice is today’s price; UnitPrice is the price actually charged on that order. Order 1005 got Palpasa Cafe at NPR 420 (a discount), and if the list price changes next month, old invoices must not change. These are two different facts.
A.4 Third Normal Form (3NF): No Transitive Dependencies
Rule: the table is in 2NF and no non-key column depends on another non-key column.
Two transitive dependencies remain:
- In Orders: OrderID → Phone → (Customer, Area). The customer’s name and area depend on who the customer is, not on the order.
- In Books: BookID → Publisher → PubCity. The city depends on the publisher, not on the book.
Fix: create Customers and Publishers tables and keep only a foreign key.
Customers
| CustomerID | FullName | Phone | Area |
|---|---|---|---|
| 1 | Sita Shrestha | 9841000001 | Baneshwor |
| 2 | Ram Thapa | 9851000002 | Patan |
| 3 | Anita Gurung | 9801000003 | Thamel |
| 4 | Bikash Rai | 9861000004 | Baneshwor |
Publishers
| PublisherID | Name | City |
|---|---|---|
| 1 | Sagarmatha Books | Kathmandu |
| 2 | Bagmati Press | Lalitpur |
Orders now holds only OrderID, OrderDate, CustomerID; Books holds BookID, Title, Author, PublisherID, ListPrice; OrderLines is unchanged.
erDiagram
CUSTOMERS ||--o{ ORDERS : places
ORDERS ||--|{ ORDER_LINES : contains
BOOKS ||--o{ ORDER_LINES : "sold in"
PUBLISHERS ||--o{ BOOKS : publishes
Figure: The 3NF design — five tables, each fact stored once.
A memory aid used by many teachers: every non-key column must depend on “the key (1NF), the whole key (2NF), and nothing but the key (3NF).”
A.5 A Note on BCNF
Boyce–Codd Normal Form (BCNF) is a slightly stricter version of 3NF: for every dependency X → Y in the table, X must be a candidate key. Most 3NF tables are already in BCNF. The difference appears only when a table has overlapping candidate keys.
Example: Pustak Ghar’s delivery roster RiderShift(Area, Day, Rider) with two business rules: each area has one rider per day, and each rider works in only one area.
- Candidate keys: (Area, Day) and (Rider, Day).
- Dependency Rider → Area holds, but Rider alone is not a candidate key.
The table is in 3NF (because Area is part of a candidate key) but not in BCNF. The anomaly: if a rider moves to a new area, several rows must be updated. The BCNF fix splits it into RiderArea(Rider, Area) and RiderShift(Rider, Day). For ICT 110, knowing that BCNF exists and why is enough; 3NF is the practical target for business databases.
Part B: Build the Database in SQLite
B.1 Create the Tables
PRAGMA foreign_keys = ON; -- SQLite enforces foreign keys only when this is on
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
full_name TEXT NOT NULL,
phone TEXT NOT NULL UNIQUE,
area TEXT NOT NULL
);
CREATE TABLE publishers (
publisher_id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
city TEXT NOT NULL
);
CREATE TABLE books (
book_id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
publisher_id INTEGER NOT NULL REFERENCES publishers(publisher_id),
list_price INTEGER NOT NULL CHECK (list_price > 0)
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
order_date TEXT NOT NULL, -- ISO format YYYY-MM-DD sorts correctly
customer_id INTEGER NOT NULL REFERENCES customers(customer_id)
);
CREATE TABLE order_lines (
order_id INTEGER NOT NULL REFERENCES orders(order_id),
book_id INTEGER NOT NULL REFERENCES books(book_id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price INTEGER NOT NULL CHECK (unit_price > 0),
PRIMARY KEY (order_id, book_id)
);
Prices are stored as whole rupees in INTEGER columns, which avoids rounding problems with decimal money.
B.2 Insert the Sample Data
INSERT INTO customers VALUES
(1, 'Sita Shrestha', '9841000001', 'Baneshwor'),
(2, 'Ram Thapa', '9851000002', 'Patan'),
(3, 'Anita Gurung', '9801000003', 'Thamel'),
(4, 'Bikash Rai', '9861000004', 'Baneshwor');
INSERT INTO publishers VALUES
(1, 'Sagarmatha Books', 'Kathmandu'),
(2, 'Bagmati Press', 'Lalitpur');
INSERT INTO books VALUES
(1, 'Muna Madan', 'Laxmi Prasad Devkota', 1, 250),
(2, 'Shirishko Phool', 'Parijat', 1, 350),
(3, 'Palpasa Cafe', 'Narayan Wagle', 2, 450),
(4, 'Karnali Blues', 'Buddhisagar', 2, 550),
(5, 'Seto Dharti', 'Amar Neupane', 1, 500),
(6, 'Sumnima', 'B. P. Koirala', 2, 300);
INSERT INTO orders VALUES
(1001, '2026-09-01', 1),
(1002, '2026-09-01', 2),
(1003, '2026-09-02', 1),
(1004, '2026-09-03', 3),
(1005, '2026-09-05', 4);
INSERT INTO order_lines VALUES
(1001, 1, 2, 250), (1001, 3, 1, 450),
(1002, 4, 1, 550),
(1003, 5, 1, 500),
(1004, 1, 1, 250), (1004, 2, 1, 350), (1004, 4, 1, 550),
(1005, 3, 2, 420);
Test the foreign key: INSERT INTO orders VALUES (1006, '2026-09-06', 99); fails with FOREIGN KEY constraint failed, because customer 99 does not exist. This is the database protecting referential integrity for you.
B.3 Queries: JOIN, GROUP BY and Subqueries
Q1. Total value of each order (three-table JOIN + GROUP BY)
SELECT o.order_id, o.order_date, c.full_name,
SUM(ol.quantity * ol.unit_price) AS order_total
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
JOIN order_lines AS ol ON ol.order_id = o.order_id
GROUP BY o.order_id, o.order_date, c.full_name
ORDER BY o.order_id;
| order_id | order_date | full_name | order_total |
|---|---|---|---|
| 1001 | 2026-09-01 | Sita Shrestha | 950 |
| 1002 | 2026-09-01 | Ram Thapa | 550 |
| 1003 | 2026-09-02 | Sita Shrestha | 500 |
| 1004 | 2026-09-03 | Anita Gurung | 1150 |
| 1005 | 2026-09-05 | Bikash Rai | 840 |
Q2. Books never ordered (subquery with NOT IN)
SELECT b.title
FROM books AS b
WHERE b.book_id NOT IN (SELECT book_id FROM order_lines);
Result: Sumnima. (Normalization made this question possible: in the original sheet, an unsold book did not exist.)
Q3. Orders above the average order value (subquery in WHERE)
WITH order_totals AS (
SELECT order_id, SUM(quantity * unit_price) AS total
FROM order_lines
GROUP BY order_id
)
SELECT order_id, total
FROM order_totals
WHERE total > (SELECT AVG(total) FROM order_totals)
ORDER BY total DESC;
The average order value is NPR 798.0, so the result is 1004 (1150), 1001 (950) and 1005 (840). WITH ... AS (a common table expression) simply names a subquery so it can be reused.
Part C: SQL Optimization
Five orders are too few to show performance differences, so this part generates a realistic-sized hypothetical dataset: 5,000 customers, 500 books, 100,000 orders and 200,237 order lines. Run the script below in Python; it uses only the standard library.
import random, sqlite3, time
random.seed(42)
con = sqlite3.connect(":memory:")
con.executescript("""
CREATE TABLE customers (customer_id INTEGER PRIMARY KEY, full_name TEXT NOT NULL,
phone TEXT NOT NULL UNIQUE, area TEXT NOT NULL);
CREATE TABLE books (book_id INTEGER PRIMARY KEY, title TEXT NOT NULL, list_price INTEGER NOT NULL);
CREATE TABLE orders (order_id INTEGER PRIMARY KEY, order_date TEXT NOT NULL,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id));
CREATE TABLE order_lines (order_id INTEGER NOT NULL REFERENCES orders(order_id),
book_id INTEGER NOT NULL REFERENCES books(book_id),
quantity INTEGER NOT NULL, unit_price INTEGER NOT NULL,
PRIMARY KEY (order_id, book_id));
""")
areas = ["Baneshwor", "Patan", "Thamel", "Koteshwor", "Kalanki", "Bhaktapur"]
con.executemany("INSERT INTO customers VALUES (?, ?, ?, ?)",
[(i, f"Customer {i}", f"98{i:08d}", random.choice(areas)) for i in range(1, 5001)])
con.executemany("INSERT INTO books VALUES (?, ?, ?)",
[(i, f"Book {i}", random.randrange(200, 1500, 10)) for i in range(1, 501)])
orders, lines = [], []
for oid in range(1, 100001):
orders.append((oid, random.randrange(0, 730), random.randint(1, 5000)))
for bid in random.sample(range(1, 501), random.randint(1, 3)):
lines.append((oid, bid, random.randint(1, 3), random.randrange(200, 1500, 10)))
con.executemany("INSERT INTO orders VALUES (?, date('2025-01-01', '+' || ? || ' days'), ?)", orders)
con.executemany("INSERT INTO order_lines VALUES (?, ?, ?, ?)", lines)
def plan(sql, params=()):
return "\n".join(row[3] for row in con.execute("EXPLAIN QUERY PLAN " + sql, params))
def timed(sql, params=(), reps=50):
t0 = time.perf_counter()
for _ in range(reps):
con.execute(sql, params).fetchall()
return (time.perf_counter() - t0) / reps * 1000 # milliseconds per run
Then run each experiment below by calling print(plan(...)) and print(timed(...)).
How to read EXPLAIN QUERY PLAN. Put EXPLAIN QUERY PLAN in front of a query and SQLite describes its strategy without running it. According to the SQLite documentation, SCAN means a full-table scan (every row is read), while SEARCH means only a subset of rows is visited, usually through an index. USING COVERING INDEX means the answer comes from the index alone, without touching the table, and USE TEMP B-TREE FOR ORDER BY means SQLite had to sort the rows itself. (The sqlite3 shell draws the same lines as a tree under a QUERY PLAN heading. SQLite warns that the exact wording can change between versions.)
About the timings: the milliseconds below were measured on one laptop with the data held in memory. Your numbers will differ; the plans and the size of the gap are what matter.
C.1 Indexes: The Biggest Single Win
A customer opens “My orders”. The query:
SELECT order_id, order_date FROM orders WHERE customer_id = 42;
| Step | EXPLAIN QUERY PLAN | Time per run |
|---|---|---|
| Before index | SCAN orders |
6.446 ms |
| Add index | CREATE INDEX idx_orders_customer ON orders(customer_id); |
|
| After index | SEARCH orders USING INDEX idx_orders_customer (customer_id=?) |
0.029 ms |
Without the index, SQLite read all 100,000 orders to find this customer’s 24. With it, SQLite jumps straight to them, like using the index at the back of a textbook instead of reading every page.
When indexes help:
- Columns used in
WHERE,JOIN ... ONandORDER BY, especially foreign keys such asorders.customer_id. - Columns with many distinct values (customer ID, phone number, date).
When indexes hurt or don’t help:
- Every index slows down writes. Each
INSERT,UPDATEorDELETEmust also update every index. In a test inserting 200,000 rows, the table with no secondary indexes took 0.42 s; the same insert with four indexes took 2.13 s. - Indexes take disk space.
- Tiny tables, or columns with very few distinct values (for example a yes/no column), rarely benefit.
- An index is useless if the query is written so the database cannot use it (see C.3).
SQLite creates some indexes automatically: the PRIMARY KEY and every UNIQUE column (such as customers.phone) get one. You saw this as sqlite_autoindex_... in the plans.
C.2 Avoid SELECT *
With idx_orders_customer in place, compare:
| Query | Plan |
|---|---|
SELECT * FROM orders WHERE customer_id = 42 |
SEARCH orders USING INDEX idx_orders_customer (customer_id=?) |
SELECT order_id FROM orders WHERE customer_id = 42 |
SEARCH orders USING COVERING INDEX idx_orders_customer (customer_id=?) |
Asking only for the columns you need let SQLite answer from the index alone (covering index). SELECT * also sends unneeded data over the network, breaks application code when someone adds a column, and hides what the query really depends on.
C.3 Write Sargable WHERE Clauses
A condition is sargable (Search ARGument-able) when the database can use an index to evaluate it. The commonest mistake is wrapping the indexed column in a function. After CREATE INDEX idx_orders_date ON orders(order_date);, count September 2026 orders two ways:
| Version | Plan | Rows | Time |
|---|---|---|---|
WHERE strftime('%Y-%m', order_date) = '2026-09' |
SCAN orders USING COVERING INDEX idx_orders_date |
4085 | 44.872 ms |
WHERE order_date >= '2026-09-01' AND order_date < '2026-10-01' |
SEARCH orders USING COVERING INDEX idx_orders_date (order_date>? AND order_date<?) |
4085 | 0.163 ms |
Both return the same 4,085 rows. In the first, SQLite must compute strftime for every one of the 100,000 entries (SCAN). In the second, the column is left “bare”, so SQLite jumps to the start of September and stops at the end (SEARCH).
The same idea applies to text: WHERE phone LIKE '%00042' (leading wildcard) gave SCAN customers USING COVERING INDEX sqlite_autoindex_customers_1, while WHERE phone = '9800000042' gave SEARCH ... (phone=?). Other non-sargable patterns: WHERE price * 1.13 > 1000 (do the arithmetic on the constant: price > 1000 / 1.13) and WHERE UPPER(full_name) = 'RAM THAPA'. If you truly must filter on an expression, SQLite, PostgreSQL and MySQL 8 can build an index on that expression, but rewriting the condition is usually simpler.
C.4 Composite Indexes for Filter + Sort
“Show customer 42’s five most recent orders”:
SELECT order_id, order_date FROM orders
WHERE customer_id = 42 ORDER BY order_date DESC LIMIT 5;
| Index available | Plan |
|---|---|
orders(customer_id) only |
SEARCH orders USING INDEX idx_orders_customer (customer_id=?) then USE TEMP B-TREE FOR ORDER BY |
orders(customer_id, order_date) |
SEARCH orders USING COVERING INDEX idx_orders_customer_date (customer_id=?) |
The two-column index stores each customer’s orders already sorted by date, so the separate sort disappears. Column order matters: put the column tested with = first, then the range or sort column.
C.5 The N+1 Query Problem
Application code often does this: fetch a list (1 query), then loop and run one more query per item (N queries).
recent = con.execute("SELECT order_id FROM orders WHERE order_date >= '2026-12-25' ORDER BY order_id").fetchall()
totals = {}
for (oid,) in recent: # one extra query per order
totals[oid] = con.execute(
"SELECT SUM(quantity*unit_price) FROM order_lines WHERE order_id = ?", (oid,)
).fetchone()[0]
For the 919 orders placed from 25 December 2026, this sent 920 queries to the database. One JOIN with GROUP BY returns exactly the same totals in 1 query:
SELECT o.order_id, SUM(ol.quantity * ol.unit_price) AS total
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.order_id
WHERE o.order_date >= '2026-12-25'
GROUP BY o.order_id;
With an in-process database like SQLite each query is cheap, but on a server database (MySQL, PostgreSQL) every query is a network round trip, so N+1 is a common reason web pages become slow.
C.6 LIMIT and Pagination
Showing page 4,501 of the order list (20 per page) with OFFSET:
| Method | Query | Plan | Time |
|---|---|---|---|
| OFFSET | ... ORDER BY order_id LIMIT 20 OFFSET 90000 |
SCAN orders |
1.116 ms |
| Keyset (seek) | ... WHERE order_id > 90000 ORDER BY order_id LIMIT 20 |
SEARCH orders USING INTEGER PRIMARY KEY (rowid>?) |
0.017 ms |
Both return the same 20 rows. OFFSET must step past 90,000 rows and throw them away, so deep pages get slower and slower. Keyset pagination remembers the last ID shown and asks for the next 20 after it. Always use LIMIT when a user only needs the first screen of results.
C.7 Optimization Checklist
- Run
EXPLAIN QUERY PLAN(SQLite) orEXPLAIN(MySQL, PostgreSQL) before and after every change. - Index foreign keys and frequent
WHERE/ORDER BYcolumns, but not everything. - Keep indexed columns bare in
WHERE; rewrite functions into ranges. - Select only the columns you need.
- Replace loops of queries with one JOIN.
- Paginate with
LIMIT, preferably keyset style.
Part D: Lab Tasks
Use the small bookshop database from Part B for tasks 1–4 and the large generated database from Part C for tasks 5–6. Expected results are given so you can check yourself.
| # | Task | Expected result |
|---|---|---|
| 1 | Revenue and number of orders per customer, highest revenue first | Sita Shrestha 2 orders, 1450; Anita Gurung 1, 1150; Bikash Rai 1, 840; Ram Thapa 1, 550 |
| 2 | Copies sold per title, most first (ties alphabetical) | Muna Madan 3; Palpasa Cafe 3; Karnali Blues 2; Seto Dharti 1; Shirishko Phool 1 |
| 3 | Revenue by publisher | Bagmati Press 2390; Sagarmatha Books 1600 (total 3990) |
| 4 | Order lines sold below list price (join order_lines to books) |
One row: order 1005, Palpasa Cafe, list 450, charged 420 |
| 5 | Show the plan for SELECT * FROM orders WHERE customer_id = 42 before and after adding an index |
SCAN orders → SEARCH orders USING INDEX ... (customer_id=?); the query returns 24 rows |
| 6 | Rewrite a non-sargable “orders in September 2026” filter and compare plans | SCAN ... → SEARCH ... (order_date>? AND order_date<?); both versions count 4085 orders |
Model SQL for tasks 1–4
```sql -- Task 1 SELECT c.full_name, COUNT(DISTINCT o.order_id) AS orders, SUM(ol.quantity * ol.unit_price) AS revenue FROM customers AS c JOIN orders AS o ON o.customer_id = c.customer_id JOIN order_lines AS ol ON ol.order_id = o.order_id GROUP BY c.customer_id, c.full_name ORDER BY revenue DESC; -- Task 2 SELECT b.title, SUM(ol.quantity) AS copies_sold FROM books AS b JOIN order_lines AS ol ON ol.book_id = b.book_id GROUP BY b.book_id, b.title ORDER BY copies_sold DESC, b.title; -- Task 3 SELECT p.name AS publisher, SUM(ol.quantity * ol.unit_price) AS revenue FROM publishers AS p JOIN books AS b ON b.publisher_id = p.publisher_id JOIN order_lines AS ol ON ol.book_id = b.book_id GROUP BY p.publisher_id, p.name ORDER BY revenue DESC; -- Task 4 SELECT ol.order_id, b.title, b.list_price, ol.unit_price FROM order_lines AS ol JOIN books AS b ON b.book_id = ol.book_id WHERE ol.unit_price < b.list_price; ``` Note `COUNT(DISTINCT o.order_id)` in Task 1: without `DISTINCT`, Sita's order 1001 would be counted twice because it has two order lines.Summary
Normalization removes duplication in steps: 1NF makes every cell atomic, 2NF removes columns that depend on only part of a composite key, and 3NF removes columns that depend on other non-key columns; BCNF tightens 3NF for tables with overlapping candidate keys. The result for Pustak Ghar is five related tables joined by primary and foreign keys. Once the design is right, performance comes from helping the database find rows without reading everything: indexes on the right columns, sargable conditions, selecting only needed columns, one JOIN instead of many small queries, and LIMIT with keyset pagination. EXPLAIN QUERY PLAN shows whether each change worked: SCAN means every row was read, SEARCH means an index narrowed the work.
Key Takeaways
- Unnormalized sheets cause update, insertion and deletion anomalies.
- 1NF = atomic values; 2NF = no partial dependency; 3NF = no transitive dependency.
- Keep historical facts (price charged) separate from current facts (list price).
- Indexes speed up reads but slow down writes; index foreign keys and frequent filters.
- Functions on indexed columns and leading-wildcard
LIKEturn aSEARCHinto aSCAN. - N+1 query loops and deep
OFFSETpages are common, avoidable slowdowns.
Discussion Questions
- A manager says, “Spreadsheets are simpler; why do we need five tables?” How would you answer using the anomalies from this lab?
- Daraz-style marketplaces show “Orders” pages with filters and many pages. Which techniques from Part C would matter most for such a page, and why?
- Should Pustak Ghar index every column “just in case”? Explain the trade-off.
Exam-Style Questions with Model Answers
1. Define 2NF and give an example of a partial dependency. (4 marks)
Model answer
A table is in 2NF if it is in 1NF and every non-key attribute depends on the whole primary key. A partial dependency exists when a non-key attribute depends on only part of a composite key. Example: in `OrderLines(OrderID, BookID, Qty, OrderDate)`, `OrderDate` depends only on `OrderID`, so it should move to the `Orders` table.2. Explain the difference between 3NF and BCNF with an example. (6 marks)
Model answer
3NF forbids non-key attributes from depending on other non-key attributes (transitive dependencies). BCNF requires that the determinant of every functional dependency is a candidate key. A table can be in 3NF but not BCNF when a non-key determinant determines part of a candidate key. Example: `RiderShift(Area, Day, Rider)` where each rider serves one area: Rider → Area holds but Rider is not a candidate key. Splitting into `RiderArea(Rider, Area)` and `RiderShift(Rider, Day)` achieves BCNF.3. What is an index? State two advantages and two disadvantages. (5 marks)
Model answer
An index is a separate, sorted data structure that lets the DBMS locate rows matching a condition without scanning the whole table, similar to a book's index. Advantages: much faster `WHERE`, `JOIN` and `ORDER BY` on indexed columns; can answer some queries from the index alone (covering index). Disadvantages: slower `INSERT`/`UPDATE`/`DELETE` because every index must be maintained; extra storage; little benefit for small tables or low-variety columns.4. Why is WHERE strftime('%Y', order_date) = '2026' slow, and how would you rewrite it? (4 marks)
Model answer
Wrapping the indexed column in a function makes the condition non-sargable: the DBMS must compute the function for every row, so it scans the table or index (`SCAN`) instead of seeking. Rewrite as a range on the bare column: `WHERE order_date >= '2026-01-01' AND order_date < '2027-01-01'`, which can use an index on `order_date` (`SEARCH`).5. What is the N+1 query problem? How is it solved? (4 marks)
Model answer
The application runs one query to fetch a list of N records and then one additional query per record to fetch related data, giving N+1 round trips. It is solved by fetching the related data in a single query with a JOIN (and `GROUP BY` if totals are needed) or with one `WHERE ... IN (...)` query. In this lab, 920 queries were replaced by one.References
- SQLite: EXPLAIN QUERY PLAN, Query Optimizer Overview, Foreign Key Support, Indexes on Expressions
- Python: sqlite3 module documentation
- PostgreSQL: Using EXPLAIN, Indexes
- MySQL 8.4: EXPLAIN Statement, Optimization and Indexes

