David Chen runs the office of a secondary school, Classes 8 to 10, from one spreadsheet: a row per student with the section, the class teacher, the parents and their phone numbers, and the marks in columns, Maths, Science, English. It worked until the second exam, when every student was copied again and the sheet said 9-A had ten students. It has five. This post designs the school database from a conversation with him: twelve tables on SQL Server and PostgreSQL, a student who is in one section per year and keeps last year's history, a mark that is a row rather than a column, four rules shown refusing bad data, five questions the examiner asks answered with real output, and the wide sheet shown counting ten students, losing a subject and giving a mother two phone numbers.

Steps in this part
  1. Download the script for your engine: p02-sqlserver.sql or p02-postgresql.sql
  2. Run it: sqlcmd -S "(localdb)\MSSQLLocalDB" -I -f 65001 -i p02-sqlserver.sql, or psql -d postgres -c "CREATE DATABASE sp_p02" then psql -d sp_p02 -e -f p02-postgresql.sql
  3. Find the table that is not in any textbook list, enrollments, and its two-column foreign key
  4. Read the end of section 3: four inserts refused, each naming the rule it broke
  5. Check Viva 3: two Science toppers in Class 8, both at 88, because RANK() keeps ties
  6. Read section 5: the wide sheet counts 10 in 9-A, the enrollments count 5
  7. Paste section 2 into the ER diagram tool and p02-sheet.csv into the normalization checker

The conversation

David: We copied every student again for the Term 1 Exam, so each child now has two rows. When the principal asked how many students are in 9-A, the sheet said 10. There are 5.

Designer: Let's start with the students. How do you tell two students apart?

David: By admission number. It is given once, the day a child joins, and it never changes. Names can repeat. The academic year starts in April, and every April the children move up. Lin was in 8-A last year; this year she is in 9-A. In one year a student is in one section of one class, never two, and roll numbers start again at 1 in every section every year.

Designer: So 9-A is not a fixed thing: it is Class 9, section A, in 2026-27, and a student's place in it is a fact about one year. Who runs a section?

David: Each section has a class teacher who takes the register and signs the report cards. Not two sections in the same year: Elena was class teacher of 8-A last year and is class teacher of 9-A this year. Each subject in a section has one teacher; Omar teaches Science to 8-B, 9-A and 10-A, and Sofia teaches Social Studies everywhere and English in 8-B. When we make the timetable we need each teacher's load.

Designer: Then a subject in a section is a pair with a teacher attached. Which subjects, and how are they examined?

David: Mathematics, Science, English and Social Studies. The spreadsheet only had columns for the first three; Social Studies marks are on a separate sheet because nobody wanted to add a column to every report. Exams happen per term, the Mid-Term Exam in July and the Term 1 Exam in September, each out of a maximum, and every student gets one mark per subject per exam. The mark sheet shows the subject, the marks, out of how much, the percentage, and the total. The principal also wants the topper in each subject for every class.

Designer: So a mark is one row: this student, this subject, this exam. A new subject is a row too, not a column on every report. And the parents?

David: The guardians. Most children have two; Leila lives with her uncle. Each has a phone and some an email, and one guardian can have more than one child here: the Okafors have Ada in 9-A and Chidi in 10-A. When Ngozi Okafor changed her number we corrected it on one row and missed the others. What the class teachers ask for most is the class list in roll-number order and the phone list of every guardian in their section.

Designer: A guardian is written down once and linked to each child with the relationship, so a new number is one change. The class list and the phone list are the first two queries.

From sentences to tables

David said became the entity and the tableBy admission number. It is given once,the day a child joins, and it neverchanges Student students admission_no, full_name, date_of_birthLin was in 8-A last year; this yearshe is in 9-A Enrollment enrollments student_id, academic_year_idsection_id, roll_noOmar teaches Science to 8-B, 9-A and10-A Section subject section_subjects section_id, subject_id, teacher_idEvery student gets one mark persubject per exam Mark marks exam_id, student_id, subject_idmarks_obtainedThe Okafors have Ada in 9-A and Chidiin 10-A Guardian guardians full_name, phone, email
Five sentences and what each became. Two of the tables are junctions: the subject taught in a section, with its teacher as payload, and the guardian linked to a child, with the relationship.
David saidEntityColumns
the year starts in April; Class 9 has one section, Class 8 twoacademic_years, classes, sectionsa section is a year, a class, a name and a class teacher; the triple is unique and so is the teacher per year
by admission number, given once, never changesstudentsadmission_no unique, full_name, date_of_birth
in one year, one section of one class; roll numbers start again every yearenrollmentsstudent_id, academic_year_id, section_id, roll_no; the roll number belongs here, not to the student
each subject in a section has one teachersubjects, section_subjectsthe pair section and subject is the key, the teacher is the payload
exams per term, out of a maximum; one mark per subject per examexams, marksmax_marks; a mark is keyed by exam, student and subject
most have two guardians; the Okafors have two children hereguardians, student_guardiansa guardian's phone and email once; the link carries the relationship
percentage, total, the topper, a teacher's loadnothingall computed from marks and section_subjects

The table that keeps history

The obvious design puts a section_id on the student, and every April it overwrites where the child was last year, together with the roll number. The enrollment is the row that remembers: this student, this year, this section, this roll number, and the primary key says a student has one of them per year.

CREATE TABLE enrollments (
    student_id        integer  NOT NULL CONSTRAINT fk_enrollments_students REFERENCES students (id),
    academic_year_id  integer  NOT NULL,
    section_id        integer  NOT NULL,
    roll_no           smallint NOT NULL,
    CONSTRAINT pk_enrollments PRIMARY KEY (student_id, academic_year_id),
    CONSTRAINT fk_enrollments_sections FOREIGN KEY (section_id, academic_year_id)
        REFERENCES sections (id, academic_year_id),
    CONSTRAINT uq_enrollments_section_roll_no UNIQUE (section_id, roll_no),
    CONSTRAINT ck_enrollments_roll_no CHECK (roll_no > 0)
);

The two-column foreign key is the subtle part. A section already knows its year, so pointing the enrollment at the pair of section and year means a student cannot be enrolled in a 2026-27 section under the 2025-26 year; the engine checks that the pair exists. For that the section table carries one unique constraint that looks redundant and is not:

CREATE TABLE sections (
    id                integer GENERATED ALWAYS AS IDENTITY CONSTRAINT pk_sections PRIMARY KEY,
    academic_year_id  integer    NOT NULL CONSTRAINT fk_sections_academic_years REFERENCES academic_years (id),
    class_id          integer    NOT NULL CONSTRAINT fk_sections_classes REFERENCES classes (id),
    name              varchar(5) NOT NULL,  -- 'A', 'B'
    class_teacher_id  integer    NOT NULL CONSTRAINT fk_sections_teachers REFERENCES teachers (id),
    CONSTRAINT uq_sections_year_class_name UNIQUE (academic_year_id, class_id, name),
    CONSTRAINT uq_sections_year_class_teacher UNIQUE (academic_year_id, class_teacher_id),
    CONSTRAINT uq_sections_id_year UNIQUE (id, academic_year_id)  -- target of fk_enrollments_sections
);

And a mark is a row with a three-part key, which is what makes Social Studies a row like any other instead of a column somebody refused to add:

CREATE TABLE marks (
    exam_id         integer  NOT NULL CONSTRAINT fk_marks_exams REFERENCES exams (id),
    student_id      integer  NOT NULL CONSTRAINT fk_marks_students REFERENCES students (id),
    subject_id      integer  NOT NULL CONSTRAINT fk_marks_subjects REFERENCES subjects (id),
    marks_obtained  smallint NOT NULL,
    CONSTRAINT pk_marks PRIMARY KEY (exam_id, student_id, subject_id),
    CONSTRAINT ck_marks_marks_obtained CHECK (marks_obtained >= 0)
);

The end of section 3 tries four bad inserts: Ada Okafor into a second section this year, a student into a 2026-27 section under the wrong year, a second Mathematics teacher for 9-A, and Ada's Term 1 Mathematics mark a second time. All four are refused on both engines; the first and the second, from PostgreSQL:

ERROR:  duplicate key value violates unique constraint "pk_enrollments"
DETAIL:  Key (student_id, academic_year_id)=(2, 2) already exists.
ERROR:  insert or update on table "enrollments" violates foreign key constraint "fk_enrollments_sections"
DETAIL:  Key (section_id, academic_year_id)=(2, 1) is not present in table "sections".

SQL Server reports the same four as errors 2627 and 547. Its foreign key message names the table but not the values, which is one reason to name every constraint: the name is the only thing both engines agree to tell you. Apart from that, the two scripts are the same text: the identity column spelling, NVARCHAR against varchar, and GO around the extra schema on SQL Server.

The ER diagram

ER diagram of the school database: academic_years, classes, teachers, sections, students, enrollments, guardians, student_guardians, subjects, section_subjects, exams and marks with their foreign keys
Twelve tables drawn by the ER diagram tool from section 2 of the PostgreSQL script. Enrollments sit between students and sections, and marks between exams, students and subjects.

Five questions the examiner asks

The class list of 9-A in roll-number order, which is the enrollment table joined to the students:

 roll_no | admission_no |   full_name   | date_of_birth
---------+--------------+---------------+---------------
       1 | ADM-2021-004 | Aarav Kapoor  | 2012-05-14
       2 | ADM-2021-007 | Ada Okafor    | 2012-02-09
       3 | ADM-2021-011 | Lin Zhang     | 2012-08-23
       4 | ADM-2021-015 | Lucia Alvarez | 2011-11-30
       5 | ADM-2021-018 | Noah Dubois   | 2012-03-17
(5 rows)

Ada Okafor's mark sheet for the Term 1 Exam, the lines and then the total, with the percentage computed against the exam's maximum:

    subject     | marks_obtained | max_marks | percentage
----------------+----------------+-----------+------------
 English        |             77 |       100 |       77.0
 Mathematics    |             91 |       100 |       91.0
 Science        |             84 |       100 |       84.0
 Social Studies |             88 |       100 |       88.0
(4 rows)
 total | out_of | percentage
-------+--------+------------
   340 |    400 |       85.0
(1 row)

The topper per subject in Class 8, both sections, with RANK() so that a tie produces two toppers instead of one chosen by accident:

WITH ranked AS (
    SELECT sub.name AS subject, st.full_name AS student,
           CONCAT(c.grade, '-', sec.name) AS section, m.marks_obtained,
           RANK() OVER (PARTITION BY m.subject_id ORDER BY m.marks_obtained DESC) AS place
    FROM marks m
    JOIN exams e ON e.id = m.exam_id
    JOIN academic_years ay ON ay.id = e.academic_year_id
    JOIN enrollments en ON en.student_id = m.student_id AND en.academic_year_id = e.academic_year_id
    JOIN sections sec ON sec.id = en.section_id
    JOIN classes c ON c.id = sec.class_id
    JOIN subjects sub ON sub.id = m.subject_id
    JOIN students st ON st.id = m.student_id
    WHERE ay.name = '2026-27' AND e.name = 'Term 1 Exam' AND c.name = 'Class 8'
)
SELECT subject, student, section, marks_obtained
FROM ranked
WHERE place = 1
ORDER BY subject, student;
    subject     |    student     | section | marks_obtained
----------------+----------------+---------+----------------
 English        | Sara Lindqvist | 8-B     |             95
 Mathematics    | Kai Zhang      | 8-A     |             97
 Science        | Diego Ramos    | 8-B     |             88
 Science        | Emma Novak     | 8-A     |             88
 Social Studies | Sara Lindqvist | 8-B     |             86
(5 rows)

Each teacher's load this year, counted from the section-subject pairs; last year's section is excluded by the year:

    teacher    | sections | subjects | assignments
---------------+----------+----------+-------------
 Sofia Petrova |        4 |        2 |           5
 Arjun Mehta   |        2 |        2 |           3
 Hannah Weber  |        3 |        1 |           3
 Omar Haddad   |        3 |        1 |           3
 Elena Rossi   |        2 |        1 |           2
(5 rows)

And the guardian phone list of 9-A, one row per guardian, so Ada appears twice:

 roll_no |    student    |   guardian    | relationship |    phone
---------+---------------+---------------+--------------+--------------
       1 | Aarav Kapoor  | Rohit Kapoor  | Father       | +00-555-0101
       2 | Ada Okafor    | Emeka Okafor  | Father       | +00-555-0103
       2 | Ada Okafor    | Ngozi Okafor  | Mother       | +00-555-0102
       3 | Lin Zhang     | Wei Zhang     | Father       | +00-555-0104
       4 | Lucia Alvarez | Mateo Alvarez | Father       | +00-555-0105
       5 | Noah Dubois   | Chloe Dubois  | Mother       | +00-555-0106
(5 rows)

The wide sheet, three ways wrong

Section 5 recreates David's sheet as a table with the marks in columns and the guardians and their phones in two cells, one row per student per exam. First, the principal's question:

SELECT COUNT(*) AS students_in_9a FROM mistake.students WHERE section = '9-A';
 students_in_9a
----------------
             10
(1 row)

Ten rows, five students, two exams. The same count on enrollments gives 5. Second, the topper per subject needs one SELECT per subject column glued with UNION ALL, the subject names typed into the query, and Social Studies is not there because it never got a column:

 subject | student_name  | marks
---------+---------------+-------
 english | Noah Dubois   |    92
 maths   | Lin Zhang     |    95
 science | Lucia Alvarez |    90
(3 rows)

The same question on the marks table is the one RANK() query above, and it has all four subjects:

    subject     |    student    | marks_obtained
----------------+---------------+----------------
 English        | Noah Dubois   |             92
 Mathematics    | Lin Zhang     |             95
 Science        | Lucia Alvarez |             90
 Social Studies | Ada Okafor    |             88
(4 rows)

Third, Ngozi Okafor's new phone number is typed into Ada's Term 1 row, and the Mid-Term row keeps the old one. The sheet now has her at two numbers, and Chidi's row has not changed at all:

 student_name |      guardian_phones
--------------+----------------------------
 Ada Okafor   | +00-555-0102, +00-555-0103
 Ada Okafor   | +00-555-0112, +00-555-0103
(2 rows)

In the proper design the number is one row in guardians, one UPDATE changes it, and both children show it:

   student    |   guardian   |    phone
--------------+--------------+--------------
 Ada Okafor   | Ngozi Okafor | +00-555-0112
 Chidi Okafor | Ngozi Okafor | +00-555-0112
(2 rows)
The normalization checker's verdict on the school's spreadsheet
David's sheet in the normalization checker: it finds the guardian lists and the student facts that depend on only part of the key, and proposes students, guardians and a link table. It keeps maths, science and english as three columns, because three named columns do not look like one repeating group; turning them into rows is the human step. Real screenshot of the tool on this site.

What this design does not do

A mark above the exam's maximum is not refused; a check constraint cannot read another table, so that rule needs a trigger or the application. Nothing insists that every student has a guardian, or that a mark is for a subject the student's section takes. The class-teacher rule, one section per teacher per year, is a constraint in the schema but no bad insert in the script demonstrates it. And both sample exams are out of 100, so the max_marks column is right but never shows a different value.

The three habits this project teaches: a fact that changes every year is a row per year, not a column overwritten in April; a mark is a row keyed by exam, student and subject, so a new subject is data; and a phone number lives in one place, or it lives in several and they disagree.

Frequently asked

What tables does a school management system database need?
Twelve in this design: academic_years, classes, sections (year, class, name, class teacher), teachers, students (an admission number that never changes), enrollments (student, year, section and roll number, one per student per year), guardians and student_guardians, subjects and section_subjects (which teacher takes which subject in which section), exams and marks (one row per exam, student and subject). Totals, percentages, toppers and teacher loads are queries.
Why should marks be rows instead of columns like maths, science, english?
Because a column per subject fixes the subjects in the table's shape: a new subject means a new column on every report, which is why Social Studies ended up on a separate sheet. One row per exam, student and subject takes a new subject as data, lets one RANK() query find the topper in every subject, and refuses a duplicate mark through its primary key. In this post's data the column design lost a subject and counted every student twice.
How do you store which section a student is in each year?
In an enrollments table keyed by student and academic year, holding the section and the roll number for that year, rather than a section column on the student that April overwrites. A two-column foreign key from (section_id, academic_year_id) to the sections table makes the engine refuse an enrollment whose year does not match the section's year, and last year's section stays as history.

Next: Part 3, an attendance system: shifts with a grace period, check-ins as times rather than letters, leave requests that only count when approved, and a sheet with one column per day.