SQL (PostgreSQL, MySQL, SQLite) Module 13 – Designing database schemas
From requirements to an ER model
Turn a written brief into entities, attributes and relationships, draw the ER diagram with crow's-foot cardinality, and build its tables and keys in SQL.
What you will learn
- Extract entities, attributes and relationships from a written brief
- Draw an ER diagram with the cardinality and optionality of each relationship at both ends
- Map the diagram to tables, primary keys, foreign keys and junction tables
- Test a model by answering the brief's questions with queries on sample data
Before you start
What you’ll learn · Before you start
What you will learn
- Extract entities, attributes and relationships from a written brief
- Draw an ER diagram with the cardinality and optionality of each relationship at both ends
- Map the diagram to tables, primary keys, foreign keys and junction tables
- Test a model by answering the brief's questions with queries on sample data
Before you start
On this page
An entity-relationship (ER) model describes what a database must remember before any table exists: the entities it keeps records of, such as students and companies; the attributes of each, such as a student’s CGPA; and the relationships between them, such as a student registers for a drive. Every relationship also gets a cardinality, one or many, and an optionality, required or not, at both of its ends. You build the model from the written requirements because a box on a diagram costs nothing to change, while a table that already holds data needs a migration. This lesson works through one brief in five steps: write down the questions the system must answer, mark the nouns and verbs of the brief, read every relationship from both ends, map the result to tables and keys, and test the tables by answering the questions with SQL.
Each example has a Run button that executes it in this page, on SQLite 3.49.1 compiled to WebAssembly by sql.js 1.14.2. The DDL that PostgreSQL 18 and MySQL 8.4 write differently is noted where it comes up.
The brief
The worked example is the placement cell of an engineering college, which keeps its records in spreadsheets and wants a database instead. The college, the companies and the people are invented.
The placement cell's brief
- Companies visit the campus to run recruitment drives. A company may run several drives in a season, each on its own date.
- A drive offers one or more roles. Each role has a title, a CTC (cost to company: the yearly pay package, in lakh rupees) and a minimum CGPA, and it is open to some branches only, such as CSE and ECE.
- Students register for drives. Each student has a roll number, a name, a branch, a CGPA on a 10-point scale and a year of graduation.
- Every drive runs its rounds in a fixed order, such as an online test, a technical interview and an HR interview. The cell records the result of each round that a registered student takes.
- Companies make offers to students for a role. A student may accept at most one offer, and may not register for another drive after accepting one.
- Each company has one faculty coordinator, and a coordinator may look after several companies.
Step 1: the questions the system must answer
A brief says what people do; a database has to answer questions. Before drawing anything, turn the brief into the questions the cell will ask, because they decide which details matter:
- Q1. Which students may register for a given drive?
- Q2. How many students of each branch hold an offer?
- Q3. Who cleared the technical round of a given drive?
- Q4. Which roles pay at least a given CTC, at which company and on which date?
- Q5. Who coordinates each company, and how many drives has it run?
A detail that answers no question and enforces no rule can wait. A question that needs a fact the brief never mentions is the first thing to ask the cell about. Q1 needs each student’s branch and CGPA, which the brief gives, and item 5’s rule about accepted offers, which turns out to need more than a key.
Step 2: nouns, verbs and what each one becomes
Nouns in a brief suggest entities, verbs suggest relationships, and the remaining details are candidate attributes. Each candidate then has to pass a test. An entity has several instances that the questions tell apart, and facts of its own: a company has a name and a coordinator, and Q5 counts drives per company. An attribute is a single fact about one instance, such as a student’s CGPA. A relationship connects instances of two entities, and it may carry facts of its own, such as the date of a registration.
| In the brief | Becomes | Why |
|---|---|---|
| company, drive, role, student, coordinator | Entities | Each has many instances, facts of its own, and appears in a question |
| branch | An entity, not just a text column | A role is open to several branches, and students and roles must use the same branch codes |
| CTC, minimum CGPA, title | Attributes of role | Single facts about one role |
| round | A weak entity | “Round 2” means nothing until you know which drive it belongs to |
| registers for | A relationship with an attribute | Students and drives are many-to-many, and a registration has a date |
| result | A relationship between a registration and a round | It records whether the student cleared that round |
| offer | An entity | It has a status that changes, and other rules refer to it |
| season | Left out for now | No question needs it; the drive date is enough to filter by period |
The judgement calls are worth saying out loud. A branch could be a text column on students, but a role refers to a list of branches, and a column holds one value, so branch becomes a table that both students and roles point to. An offer could be a plain link between a student and a role, but it moves through states (made, accepted, declined) and rule 5 depends on them, so it is an entity with its own key.
Step 3: cardinality and optionality, read from both ends
Every relationship has two ends, and each end answers two questions: at most how many, one or many (the cardinality), and at least how many, zero or one (the optionality). The reliable way to get both is to write two sentences, one from each side, and to fill in the counts:
| Relationship | From one side | From the other side |
|---|---|---|
| coordinator looks after company | A company has exactly one coordinator | A coordinator looks after zero or more companies |
| company runs drive | A drive belongs to exactly one company | A company runs zero or more drives |
| drive offers role | A role belongs to exactly one drive | A drive offers one or more roles |
| role is open to branch | A role is open to one or more branches | A branch is open for zero or more roles |
| student registers for drive | A drive has zero or more registered students | A student registers for zero or more drives |
| drive has round | A round belongs to exactly one drive | A drive has one or more rounds |
| student receives offer for role | An offer is for exactly one student and one role | A student receives zero or more offers |
Many on both sides, as in the role and branch row, is a many-to-many relationship. Many on one side and one on the other is one-to-many, the most common shape. The data you hold today cannot settle these questions, because a spreadsheet only shows what happened so far; the business rules can. When the brief is silent, ask: can two companies run one drive together? If the answer is yes, company and drive are many-to-many, and the tables change.
Crow’s-foot notation draws the answers on the ends of each line. A short bar means one, a circle means zero and a three-pronged crow’s foot means many. The mark nearest the box is the maximum and the one further away the minimum, so two bars read “exactly one”, a circle with a crow’s foot “zero or more” and a bar with a crow’s foot “one or more”.
Weak entities and keys borrowed from a parent
A weak entity cannot be identified by its own attributes alone: it borrows part of its key from the entity it
depends on. Rounds are weak. Every drive has a round 1, so a round number identifies a round only together with its
drive, and the key of a round is the pair (drive, round number). Deleting a drive leaves its rounds meaningless,
which is why the rounds table below deletes them with the drive (ON DELETE CASCADE).
Relationships that carry facts borrow keys too. A registration is identified by the student and the drive, and a round result by the registration and the round: (student, drive, round number). That result key holds the drive once, and both of its foreign keys use it, which is how the tables keep a student’s result from pointing to a round of some other drive.
The ER diagram
The placement cell's ER model, with the cardinality at both ends of every relationship
Text description of the diagram
The diagram shows ten entities as boxes and twelve relationships as lines, each named by a verb. The marks at the ends of a line say how many of the entity at that end take part: two bars mean exactly one, a bar with a crow's foot means one or many, and a circle with a crow's foot means zero or many. Round has a double border because it is a weak entity: a round is identified through the drive it belongs to.
- Coordinator looks after Company: each company has exactly one coordinator, and a coordinator looks after zero or many companies.
- Company runs Drive: each drive belongs to exactly one company, and a company runs zero or many drives.
- Drive offers Role: each role belongs to exactly one drive, and a drive offers one or many roles.
- Drive has Round: each round belongs to exactly one drive, and a drive has one or many rounds.
- Drive receives Registration: each registration is for exactly one drive, and a drive receives zero or many registrations.
- Role is open to Branch: a role is open to one or many branches, and a branch is open for zero or many roles. This is a many-to-many relationship.
- Branch has Student: each student belongs to exactly one branch, and a branch has zero or many students.
- Student makes Registration: each registration is made by exactly one student, and a student makes zero or many registrations.
- Registration earns Round result: each result belongs to exactly one registration, and a registration earns zero or many results.
- Round records Round result: each result belongs to exactly one round, and a round records zero or many results.
- Student receives Offer: each offer goes to exactly one student, and a student receives zero or many offers.
- Role leads to Offer: each offer is for exactly one role, and a role leads to zero or many offers.
Registration links students and drives, which are many-to-many; Round result links registrations and rounds; Offer links students and roles. Each of the three is drawn as an entity because it carries facts of its own: the date of a registration, whether a round was cleared, and the status of an offer.
The diagram draws registration, round result and offer as entities of their own, because each carries facts. Drawn as plain lines, they would hide the columns the cell most needs.
Step 4: from the diagram to tables
The mapping follows a handful of rules:
- Each entity becomes a table with a primary key: a code it already has, such as the roll number or the branch code, or a number the database assigns.
- Each attribute becomes a column. A required attribute is
NOT NULL, and a rule about its values is aCHECK. - A one-to-many relationship becomes a foreign key on the many side,
NOT NULLwhen the parent is required. - A many-to-many relationship becomes a junction table whose primary key is the pair of foreign keys, with the relationship’s own attributes as further columns.
- A weak entity’s primary key includes its parent’s key.
Applied to the diagram, they give eleven tables. The query at the end of the file reads SQLite’s catalog to list each table with its primary key and the tables it refers to:
-- The placement cell's ER model as tables (SQLite). Each entity is a table with a primary key; each one-to-many
-- relationship is a foreign key on the many side; each many-to-many relationship is a junction table.
CREATE TABLE coordinators (
coordinator_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE
);
CREATE TABLE companies (
company_id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
coordinator_id INTEGER NOT NULL REFERENCES coordinators (coordinator_id)
);
CREATE TABLE branches (
branch_code TEXT PRIMARY KEY NOT NULL, -- 'CSE', 'ECE', 'ME'
name TEXT NOT NULL
);
CREATE TABLE students (
roll_no TEXT PRIMARY KEY NOT NULL, -- '22CS014'
name TEXT NOT NULL,
branch_code TEXT NOT NULL REFERENCES branches (branch_code),
cgpa REAL NOT NULL CHECK (cgpa BETWEEN 0 AND 10),
grad_year INTEGER NOT NULL
);
CREATE TABLE drives (
drive_id INTEGER PRIMARY KEY,
company_id INTEGER NOT NULL REFERENCES companies (company_id),
drive_date TEXT NOT NULL, -- 'YYYY-MM-DD'
UNIQUE (company_id, drive_date)
);
CREATE TABLE roles (
role_id INTEGER PRIMARY KEY,
drive_id INTEGER NOT NULL REFERENCES drives (drive_id),
title TEXT NOT NULL,
ctc_lakh REAL NOT NULL CHECK (ctc_lakh > 0), -- cost to company, lakh rupees a year
min_cgpa REAL NOT NULL DEFAULT 0 CHECK (min_cgpa BETWEEN 0 AND 10)
);
-- Many-to-many: a role is open to several branches, and a branch to several roles.
CREATE TABLE role_branches (
role_id INTEGER NOT NULL REFERENCES roles (role_id),
branch_code TEXT NOT NULL REFERENCES branches (branch_code),
PRIMARY KEY (role_id, branch_code)
);
-- Many-to-many with a fact of its own: a student registers for a drive on a date.
CREATE TABLE registrations (
roll_no TEXT NOT NULL REFERENCES students (roll_no),
drive_id INTEGER NOT NULL REFERENCES drives (drive_id),
registered_on TEXT NOT NULL,
PRIMARY KEY (roll_no, drive_id)
);
-- A weak entity: round 2 means nothing without its drive, so the drive is part of the key.
CREATE TABLE rounds (
drive_id INTEGER NOT NULL REFERENCES drives (drive_id) ON DELETE CASCADE,
round_no INTEGER NOT NULL CHECK (round_no >= 1),
kind TEXT NOT NULL, -- 'online test', 'technical', 'hr' ...
PRIMARY KEY (drive_id, round_no)
);
-- A result belongs to one registration and one round of the same drive: both foreign keys share drive_id.
CREATE TABLE round_results (
roll_no TEXT NOT NULL,
drive_id INTEGER NOT NULL,
round_no INTEGER NOT NULL,
cleared INTEGER NOT NULL CHECK (cleared IN (0, 1)),
PRIMARY KEY (roll_no, drive_id, round_no),
FOREIGN KEY (roll_no, drive_id) REFERENCES registrations (roll_no, drive_id),
FOREIGN KEY (drive_id, round_no) REFERENCES rounds (drive_id, round_no)
);
CREATE TABLE offers (
offer_id INTEGER PRIMARY KEY,
roll_no TEXT NOT NULL REFERENCES students (roll_no),
role_id INTEGER NOT NULL REFERENCES roles (role_id),
status TEXT NOT NULL DEFAULT 'made' CHECK (status IN ('made', 'accepted', 'declined')),
UNIQUE (roll_no, role_id)
);
-- At most one accepted offer per student: a partial unique index covers only the accepted rows.
CREATE UNIQUE INDEX one_accepted_offer ON offers (roll_no) WHERE status = 'accepted';
-- What the diagram became: every table, its primary key and the tables it refers to.
SELECT t.name AS table_name,
(SELECT group_concat(c.name, ', ' ORDER BY c.pk)
FROM pragma_table_info(t.name) AS c WHERE c.pk > 0) AS primary_key,
coalesce((SELECT replace(group_concat(DISTINCT f."table" ORDER BY f."table"), ',', ', ')
FROM pragma_foreign_key_list(t.name) AS f), '-') AS refers_to
FROM sqlite_schema AS t
WHERE t.type = 'table'
ORDER BY t.rowid; Output
┌───────────────┬─────────────────────────────┬───────────────────────┐ │ table_name │ primary_key │ refers_to │ ├───────────────┼─────────────────────────────┼───────────────────────┤ │ coordinators │ coordinator_id │ - │ │ companies │ company_id │ coordinators │ │ branches │ branch_code │ - │ │ students │ roll_no │ branches │ │ drives │ drive_id │ companies │ │ roles │ role_id │ drives │ │ role_branches │ role_id, branch_code │ branches, roles │ │ registrations │ roll_no, drive_id │ drives, students │ │ rounds │ drive_id, round_no │ drives │ │ round_results │ roll_no, drive_id, round_no │ registrations, rounds │ │ offers │ offer_id │ roles, students │ └───────────────┴─────────────────────────────┴───────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < placement_schema.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Each line of the output is one rule applied. drives refers to companies because a drive has exactly one company.
role_branches and registrations are junction tables, so their primary keys are pairs. rounds has the pair
(drive_id, round_no) as its key because a round is weak. round_results has a three-column key and refers to two
tables: its rows are results of a registration in a round. Two choices go beyond the rules: UNIQUE (company_id, drive_date) says that a company runs at most one drive a day, and the partial unique index one_accepted_offer lets a
student hold any number of offers but only one with the status accepted.
What the keys refuse, and what they cannot
A model is only as good as the changes it refuses. The next example switches SQLite’s foreign key checks on, loads a few valid rows and then tries five changes that break the brief:
-- Five changes that break a rule of the placement cell, tried against the keys of the model. The tables are those
-- of the schema, cut down to the columns these checks touch.
PRAGMA foreign_keys = ON; -- SQLite checks foreign keys only after this, on every connection
CREATE TABLE students (roll_no TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL);
CREATE TABLE drives (drive_id INTEGER PRIMARY KEY, company TEXT NOT NULL);
CREATE TABLE roles (role_id INTEGER PRIMARY KEY, drive_id INTEGER NOT NULL REFERENCES drives (drive_id));
CREATE TABLE registrations (
roll_no TEXT NOT NULL REFERENCES students (roll_no),
drive_id INTEGER NOT NULL REFERENCES drives (drive_id),
PRIMARY KEY (roll_no, drive_id)
);
CREATE TABLE rounds (
drive_id INTEGER NOT NULL REFERENCES drives (drive_id) ON DELETE CASCADE,
round_no INTEGER NOT NULL CHECK (round_no >= 1),
kind TEXT NOT NULL,
PRIMARY KEY (drive_id, round_no)
);
CREATE TABLE round_results (
roll_no TEXT NOT NULL,
drive_id INTEGER NOT NULL,
round_no INTEGER NOT NULL,
cleared INTEGER NOT NULL CHECK (cleared IN (0, 1)),
PRIMARY KEY (roll_no, drive_id, round_no),
FOREIGN KEY (roll_no, drive_id) REFERENCES registrations (roll_no, drive_id),
FOREIGN KEY (drive_id, round_no) REFERENCES rounds (drive_id, round_no)
);
CREATE TABLE offers (
offer_id INTEGER PRIMARY KEY,
roll_no TEXT NOT NULL REFERENCES students (roll_no),
role_id INTEGER NOT NULL REFERENCES roles (role_id),
status TEXT NOT NULL DEFAULT 'made' CHECK (status IN ('made', 'accepted', 'declined')),
UNIQUE (roll_no, role_id)
);
CREATE UNIQUE INDEX one_accepted_offer ON offers (roll_no) WHERE status = 'accepted';
INSERT INTO students VALUES ('22CS014', 'Ananya Rao'), ('22EC007', 'Fatima Shaikh');
INSERT INTO drives VALUES (1, 'Neelgiri Softworks'), (2, 'Sahyadri Motors');
INSERT INTO roles VALUES (1, 1), (2, 1), (3, 2);
INSERT INTO registrations VALUES ('22CS014', 1), ('22EC007', 1), ('22EC007', 2);
INSERT INTO rounds VALUES (1, 1, 'online test'), (1, 2, 'technical'), (2, 1, 'aptitude'), (2, 2, 'interview');
INSERT INTO round_results VALUES ('22CS014', 1, 1, 1), ('22EC007', 1, 1, 1), ('22EC007', 2, 1, 1);
INSERT INTO offers (roll_no, role_id, status) VALUES ('22CS014', 1, 'accepted'), ('22EC007', 3, 'made');
-- 1. Ananya registers for drive 1 a second time.
INSERT INTO registrations VALUES ('22CS014', 1);
-- 2. A round 2 result of drive 2 for Ananya, who registered for drive 1 only.
INSERT INTO round_results VALUES ('22CS014', 2, 2, 1);
-- 3. A round 3 result of drive 1, which has two rounds.
INSERT INTO round_results VALUES ('22EC007', 1, 3, 0);
-- 4. A second accepted offer for Ananya.
INSERT INTO offers (roll_no, role_id, status) VALUES ('22CS014', 2, 'accepted');
-- 5. A round numbered 0.
INSERT INTO rounds VALUES (2, 0, 'pre-placement talk');
-- None of the five changed anything.
SELECT (SELECT count(*) FROM registrations) AS registrations,
(SELECT count(*) FROM round_results) AS results,
(SELECT count(*) FROM offers WHERE status = 'accepted') AS accepted_offers,
(SELECT count(*) FROM rounds) AS rounds; Output (exit status 1)
┌───────────────┬─────────┬─────────────────┬────────┐ │ registrations │ results │ accepted_offers │ rounds │ ├───────────────┼─────────┼─────────────────┼────────┤ │ 3 │ 3 │ 1 │ 4 │ └───────────────┴─────────┴─────────────────┴────────┘
Printed as an error (standard error)
Runtime error near line 45: UNIQUE constraint failed: registrations.roll_no, registrations.drive_id Runtime error near line 47: FOREIGN KEY constraint failed Runtime error near line 49: FOREIGN KEY constraint failed Runtime error near line 51: UNIQUE constraint failed: offers.roll_no Runtime error near line 53: CHECK constraint failed: round_no >= 1
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < placement_probes.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
All five are refused, and the counts at the end show that nothing changed. The duplicate registration breaks the
primary key of registrations, which SQLite reports as a failed UNIQUE constraint on the two key columns. The second
and third changes both report a failed foreign key, without naming it: Ananya never registered for drive 2, so the key
into registrations fails, and drive 1 has no round 3, so the key into rounds fails. The second accepted offer
breaks the partial unique index, and round 0 breaks the CHECK on round_no.
Two rules of the brief are not enforced by any of these keys:
- “At least one” on the many side. A foreign key makes every round name a drive that exists, but nothing makes a drive have a round, or a role at least one branch. Minimums like these are checked with a query that looks for parents without children, or, where it matters, in the code that creates the parent and its first child in one transaction.
- Rules that span tables. “No registration after an accepted offer” compares a new row of
registrationswith rows ofoffers. ACHECKlooks only at the row being written, so this needs a trigger, which a later module covers, or a check in the application, inside the same transaction as the insert.
Write such rules down next to the diagram. A model whose limits are known is safer than one that looks complete.
Step 5: testing the model with the cell’s questions
The last step runs each question as a query on a little invented data. If a question cannot be written, the model is missing something:
-- The model is only right if it answers the cell's questions. The same tables as the schema (most constraints left
-- out to keep the file short), a little invented data, and one query per question.
CREATE TABLE coordinators (coordinator_id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE companies (company_id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE,
coordinator_id INTEGER NOT NULL REFERENCES coordinators);
CREATE TABLE branches (branch_code TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL);
CREATE TABLE students (roll_no TEXT PRIMARY KEY NOT NULL, name TEXT NOT NULL,
branch_code TEXT NOT NULL REFERENCES branches, cgpa REAL NOT NULL);
CREATE TABLE drives (drive_id INTEGER PRIMARY KEY, company_id INTEGER NOT NULL REFERENCES companies,
drive_date TEXT NOT NULL);
CREATE TABLE roles (role_id INTEGER PRIMARY KEY, drive_id INTEGER NOT NULL REFERENCES drives, title TEXT NOT NULL,
ctc_lakh REAL NOT NULL, min_cgpa REAL NOT NULL);
CREATE TABLE role_branches (role_id INTEGER NOT NULL, branch_code TEXT NOT NULL, PRIMARY KEY (role_id, branch_code));
CREATE TABLE registrations (roll_no TEXT NOT NULL, drive_id INTEGER NOT NULL, PRIMARY KEY (roll_no, drive_id));
CREATE TABLE rounds (drive_id INTEGER NOT NULL, round_no INTEGER NOT NULL, kind TEXT NOT NULL,
PRIMARY KEY (drive_id, round_no));
CREATE TABLE round_results (roll_no TEXT NOT NULL, drive_id INTEGER NOT NULL, round_no INTEGER NOT NULL,
cleared INTEGER NOT NULL, PRIMARY KEY (roll_no, drive_id, round_no));
CREATE TABLE offers (offer_id INTEGER PRIMARY KEY, roll_no TEXT NOT NULL, role_id INTEGER NOT NULL,
status TEXT NOT NULL, UNIQUE (roll_no, role_id));
INSERT INTO coordinators VALUES (1, 'Dr. Meera Pillai'), (2, 'Prof. Arjun Menon');
INSERT INTO companies VALUES (1, 'Neelgiri Softworks', 1), (2, 'Sahyadri Motors', 2), (3, 'Tapti Analytics', 1);
INSERT INTO branches VALUES ('CSE', 'Computer Science'), ('ECE', 'Electronics'), ('EEE', 'Electrical'),
('ME', 'Mechanical');
INSERT INTO students VALUES
('22CS014', 'Ananya Rao', 'CSE', 8.7), ('22CS031', 'Rohit Verma', 'CSE', 7.4),
('22EC007', 'Fatima Shaikh', 'ECE', 8.1), ('22EC019', 'Karthik Iyer', 'ECE', 6.8),
('22ME003', 'Gurpreet Singh', 'ME', 7.9), ('22ME022', 'Divya Nair', 'ME', 8.4);
INSERT INTO drives VALUES (1, 1, '2026-08-12'), (2, 2, '2026-08-20'), (3, 3, '2026-09-03'), (4, 1, '2026-11-18');
INSERT INTO roles VALUES
(1, 1, 'Software Engineer', 9.5, 7.5), (2, 1, 'Test Engineer', 6.0, 6.5),
(3, 2, 'Graduate Engineer Trainee', 5.5, 7.0), (4, 3, 'Data Analyst', 8.0, 8.0),
(5, 4, 'Software Engineer', 10.5, 8.0);
INSERT INTO role_branches VALUES
(1, 'CSE'), (1, 'ECE'), (2, 'CSE'), (2, 'ECE'), (2, 'ME'), (3, 'ME'), (3, 'ECE'),
(4, 'CSE'), (4, 'ECE'), (4, 'ME'), (5, 'CSE');
INSERT INTO registrations VALUES
('22CS014', 1), ('22CS031', 1), ('22EC007', 1), ('22EC019', 1), ('22EC007', 2), ('22ME003', 2), ('22ME022', 2);
INSERT INTO rounds VALUES
(1, 1, 'online test'), (1, 2, 'technical'), (1, 3, 'hr'), (2, 1, 'aptitude'), (2, 2, 'interview'),
(3, 1, 'case study'), (3, 2, 'interview');
INSERT INTO round_results VALUES
('22CS014', 1, 1, 1), ('22CS031', 1, 1, 1), ('22EC007', 1, 1, 1), ('22EC019', 1, 1, 0),
('22CS014', 1, 2, 1), ('22CS031', 1, 2, 1), ('22EC007', 1, 2, 1),
('22CS014', 1, 3, 1), ('22CS031', 1, 3, 1), ('22EC007', 1, 3, 0),
('22EC007', 2, 1, 1), ('22ME003', 2, 1, 1), ('22ME022', 2, 1, 0), ('22EC007', 2, 2, 1), ('22ME003', 2, 2, 1);
INSERT INTO offers (roll_no, role_id, status) VALUES
('22CS014', 1, 'accepted'), ('22CS031', 2, 'made'), ('22EC007', 3, 'made'), ('22ME003', 3, 'accepted');
-- Q1. Who may register for drive 3? Right branch, CGPA high enough, and no accepted offer yet.
SELECT DISTINCT s.roll_no, s.name, s.branch_code, s.cgpa
FROM roles AS r
JOIN role_branches AS rb ON rb.role_id = r.role_id
JOIN students AS s ON s.branch_code = rb.branch_code AND s.cgpa >= r.min_cgpa
WHERE r.drive_id = 3
AND NOT EXISTS (SELECT 1 FROM offers AS o WHERE o.roll_no = s.roll_no AND o.status = 'accepted')
ORDER BY s.roll_no;
-- Q2. How many students of each branch hold an offer? Branches with none must show 0.
SELECT b.branch_code, count(DISTINCT o.roll_no) AS students_with_offers
FROM branches AS b
LEFT JOIN students AS s ON s.branch_code = b.branch_code
LEFT JOIN offers AS o ON o.roll_no = s.roll_no AND o.status IN ('made', 'accepted')
GROUP BY b.branch_code
ORDER BY b.branch_code;
-- Q3. Who cleared the technical round of drive 1?
SELECT s.name
FROM round_results AS rr
JOIN rounds AS rd ON rd.drive_id = rr.drive_id AND rd.round_no = rr.round_no
JOIN students AS s ON s.roll_no = rr.roll_no
WHERE rr.drive_id = 1 AND rd.kind = 'technical' AND rr.cleared = 1
ORDER BY s.name;
-- Q4. Which roles pay at least 8 lakh a year, at which company, on which date?
SELECT c.name AS company, d.drive_date, r.title, r.ctc_lakh
FROM roles AS r
JOIN drives AS d ON d.drive_id = r.drive_id
JOIN companies AS c ON c.company_id = d.company_id
WHERE r.ctc_lakh >= 8
ORDER BY r.ctc_lakh DESC;
-- Q5. Who coordinates each company, and how many drives has it run? Drive 4 has no rounds yet: the keys allow that.
SELECT c.name AS company, co.name AS coordinator, count(d.drive_id) AS drives,
sum(NOT EXISTS (SELECT 1 FROM rounds AS rd WHERE rd.drive_id = d.drive_id)) AS drives_without_rounds
FROM companies AS c
JOIN coordinators AS co ON co.coordinator_id = c.coordinator_id
LEFT JOIN drives AS d ON d.company_id = c.company_id
GROUP BY c.company_id
ORDER BY c.name; Output
┌─────────┬───────────────┬─────────────┬──────┐ │ roll_no │ name │ branch_code │ cgpa │ ├─────────┼───────────────┼─────────────┼──────┤ │ 22EC007 │ Fatima Shaikh │ ECE │ 8.1 │ │ 22ME022 │ Divya Nair │ ME │ 8.4 │ └─────────┴───────────────┴─────────────┴──────┘ ┌─────────────┬──────────────────────┐ │ branch_code │ students_with_offers │ ├─────────────┼──────────────────────┤ │ CSE │ 2 │ │ ECE │ 1 │ │ EEE │ 0 │ │ ME │ 1 │ └─────────────┴──────────────────────┘ ┌───────────────┐ │ name │ ├───────────────┤ │ Ananya Rao │ │ Fatima Shaikh │ │ Rohit Verma │ └───────────────┘ ┌────────────────────┬────────────┬───────────────────┬──────────┐ │ company │ drive_date │ title │ ctc_lakh │ ├────────────────────┼────────────┼───────────────────┼──────────┤ │ Neelgiri Softworks │ 2026-11-18 │ Software Engineer │ 10.5 │ │ Neelgiri Softworks │ 2026-08-12 │ Software Engineer │ 9.5 │ │ Tapti Analytics │ 2026-09-03 │ Data Analyst │ 8.0 │ └────────────────────┴────────────┴───────────────────┴──────────┘ ┌────────────────────┬───────────────────┬────────┬───────────────────────┐ │ company │ coordinator │ drives │ drives_without_rounds │ ├────────────────────┼───────────────────┼────────┼───────────────────────┤ │ Neelgiri Softworks │ Dr. Meera Pillai │ 2 │ 1 │ │ Sahyadri Motors │ Prof. Arjun Menon │ 1 │ 0 │ │ Tapti Analytics │ Dr. Meera Pillai │ 1 │ 0 │ └────────────────────┴───────────────────┴────────┴───────────────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < placement_questions.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Each question maps onto the tables without strain. Q1 joins a role to its branches and to the students of those
branches, keeps the CGPAs that are high enough, and drops Ananya, whose accepted offer bars her from more drives:
Fatima and Divya remain. Q2 starts from branches with outer joins, so the electrical branch shows 0 instead of
disappearing. Q3 finds the technical round through rounds.kind rather than a round number, which differs from drive
to drive. Q4 and Q5 follow foreign keys between roles, drives, companies and coordinators. Q5 also shows a gap that
the keys allow:
Neelgiri Softworks’ second drive has no rounds yet. Whether that is an error or a drive still being planned is a
question for the cell.
The same schema in PostgreSQL and MySQL
The ER model does not depend on the engine, but a few lines of DDL do:
| Criterion | PostgreSQL 18 | MySQL 8.4 (InnoDB) | SQLite 3.49 |
|---|---|---|---|
| A number the database assigns | integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY | INT AUTO_INCREMENT PRIMARY KEY | INTEGER PRIMARY KEY |
| Foreign keys | Always checked | Checked; write them as FOREIGN KEY (…) REFERENCES … clauses, because REFERENCES after a column is ignored | Checked after PRAGMA foreign_keys = ON, on each connection |
| CHECK constraints | Enforced | Enforced | Enforced |
| One accepted offer per student | Partial unique index (WHERE status = 'accepted') | No partial indexes: a UNIQUE index on a generated column that holds roll_no only for accepted offers | Partial unique index |
| Dates, CGPA and CTC | date, numeric(4,2), numeric(5,2) | DATE, DECIMAL(4,2), DECIMAL(5,2) | TEXT as YYYY-MM-DD, REAL |
The MySQL replacement for the partial index works because a UNIQUE index allows any number of NULLs: a generated
column such as CASE WHEN status = 'accepted' THEN roll_no END is NULL for every other offer, and the MySQL manual
allows UNIQUE secondary indexes on generated columns. Exact numeric and DECIMAL types keep a CGPA of 8.1 or a
CTC of 9.3 from picking up binary rounding. SQLite has no fixed-point type, so REAL serves for a CGPA that is only
stored and compared, while money is better stored in whole units, as a later lesson of this module shows.
GATE CS
The GATE 2027 CS syllabus lists the ER model first in its Databases section, ahead of the relational model, SQL, integrity constraints and normal forms. The mapping rules of step 4 are the ones to know for working out how many tables a diagram needs and which keys each one has.
Exercise
Exercise · Medium · SQL
Turn the hostel mess brief into tables with keys
A college hostel's mess wants to know how many meals to cook. This is its brief:
- Every resident has a roll number of exactly 8 characters, such as
B23CS041, a name and a room number. No two residents share a roll number, and all three values must be given. - The mess serves meals. A meal is identified by its date, written as text in the form
YYYY-MM-DD, and its slot, which isbreakfast,lunchordinner. Every meal has a menu. - A resident who will miss a meal marks a skip in advance. A skip names the resident and the meal and records when it was marked (
marked_at, required). A resident skips a given meal at most once, and a skip must name a resident and a meal that exist.
Rewrite the three CREATE TABLE statements so that the database itself enforces the brief: primary keys, foreign keys, NOT NULL and CHECK constraints. Keep the table and column names. Leave the statements after them as they are: they copy each test's rows into your tables in order, skip every row that breaks a NOT NULL, PRIMARY KEY, UNIQUE or CHECK rule, and report how many rows each table kept. For the skips they also report without_parent, the number of foreign-key violations that pragma_foreign_key_check finds, so a design without the right foreign keys reports 0 where the test expects more. Do not switch on PRAGMA foreign_keys: the last query checks the foreign keys itself.
When the sample tests pass, review your design with the checklist under the exercise.
Starter code · hostel_mess.sql
-- Give each table its keys and rules.
CREATE TABLE residents (roll_no TEXT, name TEXT, room_no TEXT);
CREATE TABLE meals (meal_date TEXT, slot TEXT, menu TEXT);
CREATE TABLE skips (roll_no TEXT, meal_date TEXT, slot TEXT, marked_at TEXT);
-- Leave the statements below as they are.
INSERT OR IGNORE INTO residents (roll_no, name, room_no)
SELECT roll_no, name, room_no FROM resident_rows ORDER BY n;
INSERT OR IGNORE INTO meals (meal_date, slot, menu)
SELECT meal_date, slot, menu FROM meal_rows ORDER BY n;
INSERT OR IGNORE INTO skips (roll_no, meal_date, slot, marked_at)
SELECT roll_no, meal_date, slot, marked_at FROM skip_rows ORDER BY n;
SELECT 'residents' AS tbl, count(*) AS kept, NULL AS without_parent FROM residents
UNION ALL
SELECT 'meals', count(*), NULL FROM meals
UNION ALL
SELECT 'skips', count(*), (SELECT count(*) FROM pragma_foreign_key_check('skips')) FROM skips; The sample tests · tests.yaml
# Sample tests: each one fills resident_rows, meal_rows and skip_rows, runs your script and compares the counts it
# reports (in any order).
tests:
- name: keeps valid rows and refuses repeated keys, bad slots and short roll numbers
setup: |
CREATE TABLE resident_rows (n INTEGER, roll_no, name, room_no);
CREATE TABLE meal_rows (n INTEGER, meal_date, slot, menu);
CREATE TABLE skip_rows (n INTEGER, roll_no, meal_date, slot, marked_at);
INSERT INTO resident_rows VALUES
(1, 'B23CS041', 'Asha Kulkarni', 'A-12'), (2, 'B23EC007', 'Imran Qureshi', 'A-14'),
(3, 'B23ME019', 'Lakshmi Reddy', 'B-03'), (4, 'B23CS041', 'Asha K.', 'A-12'),
(5, 'B23CS4', 'Tenzin Norbu', 'B-07');
INSERT INTO meal_rows VALUES
(1, '2026-11-02', 'breakfast', 'poha'), (2, '2026-11-02', 'lunch', 'rajma chawal'),
(3, '2026-11-02', 'dinner', 'roti, dal, aloo gobi'), (4, '2026-11-03', 'breakfast', 'idli sambar'),
(5, '2026-11-02', 'lunch', 'veg biryani'), (6, '2026-11-03', 'snacks', 'samosa');
INSERT INTO skip_rows VALUES
(1, 'B23CS041', '2026-11-02', 'lunch', '2026-11-01 21:10'),
(2, 'B23EC007', '2026-11-02', 'dinner', '2026-11-02 08:05'),
(3, 'B23CS041', '2026-11-02', 'lunch', '2026-11-02 09:30'),
(4, 'B23ME019', '2026-11-03', 'breakfast', '2026-11-02 22:45');
expected:
columns: [tbl, kept, without_parent]
rows:
- ['residents', 3, null]
- ['meals', 4, null]
- ['skips', 3, 0]
- name: finds skips for residents or meals that do not exist
setup: |
CREATE TABLE resident_rows (n INTEGER, roll_no, name, room_no);
CREATE TABLE meal_rows (n INTEGER, meal_date, slot, menu);
CREATE TABLE skip_rows (n INTEGER, roll_no, meal_date, slot, marked_at);
INSERT INTO resident_rows VALUES (1, 'B23CS041', 'Asha Kulkarni', 'A-12'), (2, 'B23EC007', 'Imran Qureshi', 'A-14');
INSERT INTO meal_rows VALUES (1, '2026-11-02', 'lunch', 'rajma chawal'), (2, '2026-11-02', 'dinner', 'paneer, roti');
INSERT INTO skip_rows VALUES
(1, 'B23CS041', '2026-11-02', 'lunch', '2026-11-01 21:10'),
(2, 'B23XX999', '2026-11-02', 'dinner', '2026-11-02 08:05'),
(3, 'B23EC007', '2026-11-02', 'breakfast', '2026-11-01 22:00'),
(4, 'B23EC007', '2026-11-03', 'lunch', '2026-11-02 07:40');
expected:
columns: [tbl, kept, without_parent]
rows:
- ['residents', 2, null]
- ['meals', 2, null]
- ['skips', 4, 3]
- name: refuses missing values, including a missing slot
setup: |
CREATE TABLE resident_rows (n INTEGER, roll_no, name, room_no);
CREATE TABLE meal_rows (n INTEGER, meal_date, slot, menu);
CREATE TABLE skip_rows (n INTEGER, roll_no, meal_date, slot, marked_at);
INSERT INTO resident_rows VALUES (1, 'B23CS060', 'Neha Joshi', 'C-01'), (2, 'B23CS061', 'Vikram Das', NULL),
(3, NULL, 'Rhea Fernandes', 'C-02');
INSERT INTO meal_rows VALUES
(1, '2026-11-04', 'lunch', 'chole bhature'), (2, '2026-11-04', 'dinner', NULL),
(3, '2026-11-04', NULL, 'khichdi'), (4, '2026-11-05', 'breakfast', 'upma');
INSERT INTO skip_rows VALUES
(1, 'B23CS060', '2026-11-04', 'lunch', NULL),
(2, 'B23CS060', '2026-11-05', 'breakfast', '2026-11-04 21:00');
expected:
columns: [tbl, kept, without_parent]
rows:
- ['residents', 1, null]
- ['meals', 2, null]
- ['skips', 1, 0] A hint
A meal has no single identifying column: its key is the pair, PRIMARY KEY (meal_date, slot), and a skip refers to that pair with one foreign key over both columns, FOREIGN KEY (meal_date, slot) REFERENCES meals (meal_date, slot). A skip's own key is the resident and the meal together. In SQLite a primary key column that is not an INTEGER PRIMARY KEY accepts NULL unless you also write NOT NULL, and a CHECK lets NULL through, so the required columns need NOT NULL as well.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
The sample tests check the keys and constraints. Review the rest of your design yourself with this checklist:
- Can each question of the mess be answered: how many residents eat lunch on a given day, and who skipped dinner?
- Does every table have a primary key, and is every required value
NOT NULL? - Is each relationship’s cardinality written down from both ends, and does each foreign key sit on the many side?
- Which rules of a real mess would still need more than keys? A skip marked after a cut-off time is one.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is a weak entity, and how does it become a table?
A weak entity is one that its own attributes cannot identify: it depends on another entity, its owner, and borrows
part of its key from it. A round of a recruitment drive is an example, because every drive has a round 1, so “round
1” identifies nothing until you add the drive. As a table, a weak entity gets a primary key made of its owner’s key
and its own partial key, here PRIMARY KEY (drive_id, round_no), with drive_id also a foreign key to the owner.
Because the weak rows mean nothing without their owner, the foreign key often has ON DELETE CASCADE. In a diagram it
is drawn with a double border, and the relationship to its owner is called identifying.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
You have 15 minutes to turn this brief into tables: an inter-college hackathon has teams of 2 to 4 students, each team picks one problem statement and submits up to 3 versions of its project, and three judges score every submission. Walk through your method and the tables you end with.
-
Mid to senior
Your ER diagram says that every order line belongs to exactly one order and that every order has at least one line. Which of the two rules do your foreign keys enforce, and how do you enforce the other in PostgreSQL, MySQL and SQLite?
Key takeaways
- Start from the questions the system must answer: they decide which nouns become entities, which details become attributes and what can wait.
- Read every relationship from both ends, writing down the minimum and the maximum; many on both sides means a junction table, one-to-many a foreign key on the many side.
- A weak entity, such as a round of a drive, takes its owner’s key into its own primary key.
- Foreign keys enforce “exactly one parent” but not “at least one child”, and a
CHECKcannot look at other rows: write those rules down and check them with queries, triggers or application code. - Test a model by running its questions as queries on sample data before anyone builds on it.
References
- PostgreSQL 18 Documentation: Data Definition (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Constraints (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Identity Columns (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Partial Indexes (The PostgreSQL Global Development Group)
- SQLite: CREATE TABLE (SQLite)
- SQLite: Foreign Key Support (SQLite)
- SQLite: Partial Indexes (SQLite)
- SQLite: Pragma statements (table_info, foreign_key_list, foreign_key_check) (SQLite)
- MySQL 8.4 Reference Manual: CREATE TABLE Statement (Oracle Corporation)
- MySQL 8.4 Reference Manual: FOREIGN KEY Constraints (Oracle Corporation)
- MySQL 8.4 Reference Manual: CHECK Constraints (Oracle Corporation)
- MySQL 8.4 Reference Manual: Secondary Indexes and Generated Columns (Oracle Corporation)
- MySQL 8.4 Reference Manual: CREATE INDEX Statement (unique indexes and NULL) (Oracle Corporation)
- GATE 2027 Computer Science and Information Technology (CS) syllabus (Indian Institute of Technology Madras (GATE 2027))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress