Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 30 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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

  1. Companies visit the campus to run recruitment drives. A company may run several drives in a season, each on its own date.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

ER diagram of the placement cell: ten entities from Coordinator to Offer, joined by twelve relationships in crow's foot notation.CoordinatorCompanyDriveRoleBranchStudentRegistrationRoundRound resultOfferlooks afterrunsoffershasreceivesopen tohasmakesearnsrecordsreceivesleads to

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.

  1. Coordinator looks after Company: each company has exactly one coordinator, and a coordinator looks after zero or many companies.
  2. Company runs Drive: each drive belongs to exactly one company, and a company runs zero or many drives.
  3. Drive offers Role: each role belongs to exactly one drive, and a drive offers one or many roles.
  4. Drive has Round: each round belongs to exactly one drive, and a drive has one or many rounds.
  5. Drive receives Registration: each registration is for exactly one drive, and a drive receives zero or many registrations.
  6. 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.
  7. Branch has Student: each student belongs to exactly one branch, and a branch has zero or many students.
  8. Student makes Registration: each registration is made by exactly one student, and a student makes zero or many registrations.
  9. Registration earns Round result: each result belongs to exactly one registration, and a registration earns zero or many results.
  10. Round records Round result: each result belongs to exactly one round, and a round records zero or many results.
  11. Student receives Offer: each offer goes to exactly one student, and a student receives zero or many offers.
  12. 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 a CHECK.
  • A one-to-many relationship becomes a foreign key on the many side, NOT NULL when 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 eleven tables, and what each one refers to SQL · placement_schema.sql
-- 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

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 the brief, all refused SQL · placement_probes.sql
-- 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

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 registrations with rows of offers. A CHECK looks 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 five questions, answered from the tables SQL · placement_questions.sql
-- 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

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:

What changes in the placement cell's DDL
Criterion PostgreSQL 18MySQL 8.4 (InnoDB)SQLite 3.49
A number the database assigns integer GENERATED ALWAYS AS IDENTITY PRIMARY KEYINT AUTO_INCREMENT PRIMARY KEYINTEGER PRIMARY KEY
Foreign keys Always checkedChecked; write them as FOREIGN KEY (…) REFERENCES … clauses, because REFERENCES after a column is ignoredChecked after PRAGMA foreign_keys = ON, on each connection
CHECK constraints EnforcedEnforcedEnforced
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 offersPartial 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 is breakfast, lunch or dinner. 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.

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.

  1. Question 1 of 5 Put the steps of turning a brief into tables in the order this lesson uses them.

    Give each item its position, from 1 (first).

    Show the answer to question 1

    Answer:

    1. Write down the questions the system must answer
    2. Mark the nouns and verbs of the brief and decide what each one becomes
    3. Give every relationship its cardinality and optionality, read from both ends
    4. Turn entities into tables with keys, and relationships into foreign keys or junction tables
    5. Run the questions as queries against sample data

    The questions come first because they decide which nouns matter. Cardinality is settled before any table exists, because it decides where each foreign key goes. Running the questions at the end tests the whole model: a question that no query can answer points to a missing entity, attribute or relationship.

  2. Question 2 of 5 A drive has one or more rounds, and each round belongs to exactly one drive. Where does the link go?

    Choose one answer.

    Show the answer to question 2

    Answer: In rounds, as drive_id, which is also part of the rounds key

    The foreign key sits on the many side of a one-to-many relationship, here rounds. Because a round number only means something inside its drive, drive_id is also part of the key of rounds: (drive_id, round_no). A junction table is for many-to-many relationships, and a list in one column cannot carry a foreign key.

  3. Question 3 of 5 rounds.drive_id is NOT NULL and refers to drives. Which rule of the diagram does that enforce?

    Choose one answer.

    Show the answer to question 3

    Answer: Every round belongs to exactly one existing drive

    A foreign key constrains the child row: each round must name one drive that exists, and NOT NULL makes the parent required. Nothing in rounds can make a drive have a round, so "one or more rounds per drive" needs a check of its own, which is why the lesson's last query looks for drives without rounds.

  4. Question 4 of 5 round_results has FOREIGN KEY (roll_no, drive_id) REFERENCES registrations and FOREIGN KEY (drive_id, round_no) REFERENCES rounds, and foreign keys are switched on. Student 22CS014 is registered for drive 1 only; drive 1 has rounds 1 and 2, and drive 2 has rounds 1 to 3. Which insert succeeds?

    Read the code, then choose one answer.

    INSERT INTO round_results VALUES ('22CS014', 1, 2, 1);  -- A
    INSERT INTO round_results VALUES ('22CS014', 2, 3, 1);  -- B
    INSERT INTO round_results VALUES ('22CS014', 1, 3, 1);  -- C
    Show the answer to question 4

    Answer: Only A

    A names a registration that exists, (22CS014, 1), and a round that exists, (1, 2). B fails the first foreign key, because the student never registered for drive 2. C fails the second, because drive 1 has no round 3. The shared drive_id column is what makes both keys talk about the same drive.

  5. Question 5 of 5 Why does the model make branch an entity with its own table instead of a text column on each role?

    Choose one answer.

    Show the answer to question 5

    Answer: A role is open to several branches, and students and roles must name the same set of branches

    Two facts decide it. A role lists several branches, which a single column cannot hold, so the role and branch link needs a junction table; and both students and roles refer to branches, so one table of branch codes keeps their spellings equal through foreign keys. Many nouns of a brief stay attributes.

SQL to ER Diagram Paste the CREATE TABLE statements of this lesson, or of your exercise, to see them drawn as a crow's-foot ER diagram.

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.

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

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

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

    Model answer, Pro. First step and follow-ups

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 CHECK cannot 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.