School ·

School management system database design: tables and SQL

A sound school or student management system database design has a small core and one rule. people holds every human once, and students, guardians and staff are roles that point at it. student_guardians links children to adults, many to many, with the relationship and contact flags on the link. academic_years and terms are real rows, sections belong to a year, and enrollments places a student in a section for a date range. A student's class is never a column on the student. It is a dated enrollment row, and a section change closes one row and opens another. Attendance is one row per student per session, marks are one row per student per assessment, report cards are computed from the marks, and fees are an append-only ledger.

The tables at a glance

TableKey columnsPurpose
peoplegiven_name, family_name, date_of_birthEvery human once, whatever roles they hold
studentsperson_id (unique), admission_no (unique), statusThe student role. No class or section column
guardiansperson_id (unique)The guardian role
staffperson_id (unique), staff_noTeachers and office staff
student_guardiansstudent_id + guardian_id, relationship, is_primary, receives_communications, can_pick_up, can_view_recordsMany-to-many family links, with permissions on the link
contact_pointsperson_id, kind, value, is_preferred, verified_atPhones and emails, more than one per person
academic_years + termsstarts_on, ends_on; results_published_atThe calendar everything else is scoped to
grade_levels + sectionsname; academic_year_id, grade_level_id, nameGrade 7 is permanent; 7-B in 2026-27 is a row for that year
enrollmentsstudent_id, section_id, starts_on, ends_on, end_reasonWhich section a student was in, and when. Never overwritten
promotion_decisionsenrollment_id, outcome, to_section_idThe year-end decision per student, recorded before the run
subjects + teaching_assignmentssection_id, subject_id, teacher_idWho teaches what to which section
periods, rooms, timetable_slotsterm_id, weekday, period_id, room_idThe weekly timetable, with clashes rejected by unique keys
attendance_codescode, counts_as_present, is_excusedPresent, late, absent, excused, as data rather than strings
attendance_sessions + attendance_markssection_id, session_date, timetable_slot_id; student_id, codeOne register per section per day or period, one mark per student
assessments + marksterm_id, max_score, weight; status, scoreWhat was tested and what each student scored
grade_scales + grade_bandsletter, min_percentPercent to letter, per term
subject_results (view)student_id, term_id, subject_id, percentThe report card, derived from marks
invoices, payments, fee_ledgerdue_on; receipt_no, amount_minor; signed amount_minor, entry_typeFees as documents plus an append-only money ledger
audit_logtable_name, old_row, new_row, changed_byEvery correction to attendance or marks

Download the SQL file and ER diagram

Download the PostgreSQL schema and sample data (.sql) · Download the ER diagram (.svg)

Run it once in an empty practice database with PostgreSQL 16 or later:

psql -v ON_ERROR_STOP=1 -d your_practice_database -f school-example.sql

It creates a school_example schema with every table above, the triggers and views this article relies on, and a fictional school: three students (two of them siblings), a teacher who is also a parent, a mid-year section transfer, a few days of registers, a term of marks and three invoices, one paid by a cheque that bounces. It also attempts five things a school database must refuse and prints a notice as each is rejected. The final query returns the class history of student ADM-1001: 6-A in 2025-26 ending in promotion, 7-A from 1 August to 13 September ending in a transfer, and 7-B from 14 September, still open. It needs the btree_gist extension, which ships with PostgreSQL, and it stops at the existing schema if you run it twice.

ER diagram for a student database management system: people linked one-to-one to students, guardians and staff; students and guardians many-to-many through student_guardians; dated enrollments joining students to sections, which belong to academic years and terms; attendance sessions and marks; assessments and marks feeding a derived subject_results view; invoices and payments writing to an append-only fee ledger.

The download is the data model, not an application: no admissions, messaging, sign-in or per-school isolation. Before production, add authentication, an institution_id on every school-scoped table if you will ever host more than one school, and a restricted database role for the application, because triggers do not constrain the schema's owner.

How to design a school management system database, step by step

  1. Write down the questions the school asks every week. Who is in 7-B today? Which children were absent yesterday, and who are their primary contacts? What is this student's attendance rate for the term? What goes on the report card? Who owes fees, and for how long? Every table you add should exist because one of these questions needs it.
  2. Separate people from their roles. One human can be a student, a guardian and a staff member. Identity goes in one table; each role is its own table.
  3. Make time a first-class entity. Academic years and terms are rows with dates, and anything that belongs to a year (sections, enrollments, timetables, assessments, invoices) points at one.
  4. Split facts that change from facts that happened. A misspelt name is edited. A student being in 7-A from August to September happened, so it becomes a dated row, never an updated column.
  5. Close every set and every overlap with constraints. Status values, attendance codes and ledger entry types are check constraints or lookup tables. Overlapping enrollments and double-booked teachers are rejected by the database, not by a form. The full list is in the constraints section.
  6. Derive what can be derived. Attendance rates, subject percentages, letter grades and fee balances are queries over the rows underneath them. Store a number only when it was decided by a person, such as a mark or a payment.
  7. Test with a real school year. Seed a promotion, a transfer, an excused absence, an exempt student and a bounced payment, then check the example queries against totals worked out by hand.

Why enrollment comes first

The ER diagram most tutorials draw for a student database management system has a students table with a class_id column, a classes table, a teachers table, and attendance and marks hanging off the student. It is also the obvious thing for a coding agent to produce when you ask for a student management system without saying more.

That schema is wrong in one specific place. students.class_id is a value somebody overwrites. The day a child moves from 7-A to 7-B, the database forgets they were ever in 7-A, and every report that joins through the current class starts rewriting the past: last month's 7-A register loses a child, and last year's results appear under this year's class.

At year end, promotion becomes a bulk update students set class_id = … that destroys a year of history in one statement, and a student repeating a year looks like one who was never promoted.

Class membership is an enrollment: a row with a student, a section and a date range. Moving section closes one row and opens another. Promotion closes every row in the year and opens one in next year's section. The history is the table.

This is not an edge case. In the inventory of school systems we keep for the School Management planner, counted across 46 surveyed sources (live products, open-source systems, marketplace scripts and market sources), classes and sections appear in 36, academic years and terms in 29, student lifecycle and promotion in 25, and class or section transfer in 18. A schema with a class_id on the student cannot represent the last two at all.

People, students, guardians and staff

create table students (
  id            bigint generated always as identity primary key,
  person_id     bigint not null unique references people(id),
  admission_no  text   not null unique,
  admitted_on   date   not null,
  status        text   not null default 'active'
                check (status in ('active', 'suspended', 'withdrawn', 'graduated'))
);

people holds the name and date of birth. students, guardians and staff each carry a unique person_id, so one human appears once however many roles they have. Schools need this more than most domains: teachers are parents at their own school, and former students come back as staff or guardians. In the sample data, Usman Raza teaches maths and is Sara's father, and he exists once.

create table student_guardians (
  student_id               bigint  not null references students(id),
  guardian_id              bigint  not null references guardians(id),
  relationship             text    not null
                           check (relationship in ('mother', 'father', 'grandparent',
                                                   'legal_guardian', 'sponsor', 'other')),
  is_primary               boolean not null default false,
  receives_communications  boolean not null default true,
  can_pick_up              boolean not null default false,
  can_view_records         boolean not null default true,
  primary key (student_id, guardian_id)
);
create unique index one_primary_guardian
  on student_guardians (student_id) where is_primary;

The flags live on the link, not on the guardian, because they are facts about one adult's relationship with one child. A parent can be the primary contact for one child and not for a stepchild; a separated parent may be allowed to pick up but not to see records; a grandparent may collect a child every day and never receive a fee reminder. The partial unique index allows any number of guardians per student but exactly one primary.

Guardian association is in 35 of the 46 sources we counted, and the planner's research treats it as core: guardians are never plain text on the student. Phone numbers and emails go in contact_points, several per person, with a verified_at so you can tell a number someone typed from a number that has been confirmed.

The test of this model is the query a school sends every week: one message per guardian, not per child, to verified numbers only.

select gp.given_name || ' ' || gp.family_name as guardian,
       c.value as phone,
       string_agg(sp.given_name || ' (' || sec.name || ')', ', '
                  order by sp.given_name) as children
from enrollments e
join sections sec          on sec.id = e.section_id
join students st           on st.id = e.student_id
join people sp             on sp.id = st.person_id
join student_guardians sg  on sg.student_id = st.id
join guardians g           on g.id = sg.guardian_id
join people gp             on gp.id = g.person_id
join contact_points c      on c.person_id = gp.id and c.kind = 'phone'
                          and c.is_preferred and c.verified_at is not null
where sg.receives_communications
  and daterange(e.starts_on, e.ends_on, '[]') @> date '2026-10-18'
group by gp.id, c.value
order by guardian;

Against the sample data it returns three guardians, with Nadia Qureshi once for both children: Ayesha (7-B) and Bilal (6-A). Tariq Qureshi is left out because his number was never verified. If your schema cannot write this query, the contact model is wrong.

Academic years and terms are rows, not labels

create table academic_years (
  id         bigint generated always as identity primary key,
  name       text not null unique,          -- '2026-27'
  starts_on  date not null,
  ends_on    date not null,
  check (ends_on > starts_on),
  exclude using gist (daterange(starts_on, ends_on, '[]') with &&)
);

A year stored as the text '2026-27' on fifteen tables cannot answer "which year is today in" and drifts the first time someone types '2026/27'. As a row with dates, every query resolves the year by range, and the exclusion constraint makes overlapping years impossible. terms have the same shape plus a grade_scale_id (the scale that applied that term) and a results_published_at column that locks the term's marks.

Classes, sections and enrollment history

create table sections (
  id                   bigint generated always as identity primary key,
  academic_year_id     bigint not null references academic_years(id),
  grade_level_id       bigint not null references grade_levels(id),
  name                 text   not null,     -- '7-B'
  homeroom_teacher_id  bigint references staff(id),
  unique (academic_year_id, name)
);

create table enrollments (
  id          bigint generated always as identity primary key,
  student_id  bigint not null references students(id),
  section_id  bigint not null references sections(id),
  starts_on   date   not null,
  ends_on     date,                          -- last day in this section; null = current
  end_reason  text   check (end_reason in ('promoted', 'retained', 'transferred',
                                           'withdrawn', 'graduated')),
  check (ends_on is null or ends_on >= starts_on),
  check ((ends_on is null) = (end_reason is null)),
  exclude using gist (student_id with =,
                      daterange(starts_on, ends_on, '[]') with &&)
);

Two details carry the design. First, a section belongs to a year. Grade 7 is permanent and lives in grade_levels; 7-B in 2026-27 is a different row from 7-B in 2025-26, with a different homeroom teacher and a different set of children. Reusing one 7-B row across years is the same bug as students.class_id, one table over.

Second, the exclusion constraint says a student cannot be in two sections on the same day. It needs the btree_gist extension so that student_id with = can sit in a GiST index next to the date range. An open enrollment has a null ends_on, which the range treats as unbounded, so a second open enrollment for the same child is rejected too.

A section change is two statements in one transaction:

update enrollments set ends_on = '2026-09-13', end_reason = 'transferred'
where student_id = 1 and section_id = 3 and ends_on is null;
insert into enrollments (student_id, section_id, starts_on) values (1, 4, '2026-09-14');

A trigger on enrollments refuses any update that changes student_id, section_id or starts_on, and any enrollment whose dates fall outside its section's academic year. Closing an enrollment is allowed; rewriting one is not.

Promotion is a recorded decision, then a set-based run

Year-end promotion is where tutorial schemas break, and the planner's research flags it as a common miss in builds from a weak prompt. Record each student's outcome first, because promoted, retained and graduated are decisions somebody made and will be asked about:

create table promotion_decisions (
  enrollment_id  bigint primary key references enrollments(id),
  outcome        text   not null check (outcome in ('promoted', 'retained', 'graduated')),
  to_section_id  bigint references sections(id),
  decided_by     bigint not null references staff(id),
  check ((outcome = 'graduated') = (to_section_id is null))
);

Then the run closes every decided enrollment at the end of its year and opens the next one at the start of the target year, in one statement:

with closed as (
  update enrollments e
  set ends_on = ay.ends_on, end_reason = d.outcome
  from promotion_decisions d, sections s, academic_years ay
  where d.enrollment_id = e.id and s.id = e.section_id
    and ay.id = s.academic_year_id and e.ends_on is null
  returning e.student_id, d.to_section_id
)
insert into enrollments (student_id, section_id, starts_on)
select c.student_id, c.to_section_id, ay.starts_on
from closed c
join sections s        on s.id = c.to_section_id
join academic_years ay on ay.id = s.academic_year_id;

Graduates have no target section, so they are closed and not re-enrolled; set their students.status to graduated in the same transaction. A retained student gets a new enrollment in the same grade, and the history says so. Nothing from the old year changes except end dates, so last year's registers and marks still resolve to the section the child was really in. Afterwards, any open enrollment in a year that has ended is a student the run missed.

Subjects and the timetable

create table timetable_slots (
  id                      bigint generated always as identity primary key,
  term_id                 bigint   not null references terms(id),
  teaching_assignment_id  bigint   not null,
  section_id              bigint   not null,
  teacher_id              bigint   not null,
  weekday                 smallint not null check (weekday between 1 and 7),  -- 1 = Monday
  period_id               bigint   not null references periods(id),
  room_id                 bigint   references rooms(id),
  foreign key (teaching_assignment_id, section_id, teacher_id)
    references teaching_assignments (id, section_id, teacher_id) on update cascade,
  unique (term_id, weekday, period_id, section_id),   -- a class is in one place
  unique (term_id, weekday, period_id, teacher_id),   -- a teacher is in one place
  unique (term_id, weekday, period_id, room_id)       -- a room holds one class
);

teaching_assignments says who teaches which subject to which section. A timetable slot places one assignment in a period on a weekday for a term.

The three clash rules the planner's research asks for (class, teacher and room double-booked) are plain unique keys here. To put teacher_id in a key, the slot carries copies of section_id and teacher_id, and the composite foreign key guarantees the copies match the assignment. If a subject moves to another teacher, on update cascade moves the copies, and the unique key rejects the change if that teacher is already busy. Class timetables appear in 29 of the 46 sources we counted; automatic timetable generation is a much harder problem and does not belong in a first data model.

Attendance: one row per student per session

create table attendance_sessions (
  id                 bigint generated always as identity primary key,
  section_id         bigint not null references sections(id),
  session_date       date   not null,
  timetable_slot_id  bigint references timetable_slots(id),  -- null = daily roll call
  taken_by           bigint not null references staff(id),
  taken_at           timestamptz not null default now(),
  unique nulls not distinct (section_id, session_date, timetable_slot_id)
);

create table attendance_marks (
  session_id    bigint  not null references attendance_sessions(id),
  student_id    bigint  not null references students(id),
  code          text    not null references attendance_codes(code),
  minutes_late  integer check (minutes_late >= 0),
  note          text,
  marked_by     bigint  not null references staff(id),
  marked_at     timestamptz not null default now(),
  primary key (session_id, student_id)
);

A session is one register being taken: a section's daily roll call, or one timetabled period when the school records attendance per lesson. A mark is one student's status in that session. Daily attendance is in 34 of the 46 sources, period or subject attendance in 21, and this one shape handles both: a daily register has a null slot. unique nulls not distinct (PostgreSQL 15 and later) treats that null as a value, so a section cannot have two daily registers on the same day.

Three tutorial designs to avoid. A table with a column per day of the month cannot hold periods. A table that stores only absences cannot tell a present child from a register nobody took. And a free-text status column ends up holding A, abs and Absent. Codes live in attendance_codes, with counts_as_present and is_excused as data, so whether "late" counts towards the rate is a row a school can change, not an if in a report.

A trigger rejects a mark for any student not enrolled in the session's section on that date, which is why the sample file refuses to put Bilal on the 7-B register. Attendance gets corrected (a note arrives, a teacher tapped the wrong child), so marks can be updated, but every update or delete writes the old and new row to audit_log, with the acting user read from a session setting the application sets per request. In the sample, Sara's absence on 17 September becomes excused, and the log keeps the original A.

Assessments, marks and report cards

Gradebook and marks entry are among the most common capabilities in the inventory, in 38 of 46 sources, and report cards are in 35. The model is three tables and a view.

assessments belongs to a term and a teaching assignment, with a max_score and a weight. marks has one row per student per assessment, with a status of scored, absent or exempt and a score only when scored. That status is a hidden rule: an absent student typically scores zero, while an exempt student should not have the assessment counted at all. A null score for both makes them indistinguishable.

The report card is a view, not a table:

create view subject_results as
select m.student_id, a.term_id, ta.subject_id,
       round(100 * sum(case when m.status = 'scored'
                            then m.score / a.max_score else 0 end * a.weight)
                 / sum(a.weight), 1) as percent
from marks m
join assessments a           on a.id = m.assessment_id
join teaching_assignments ta on ta.id = a.teaching_assignment_id
where m.status <> 'exempt'
group by m.student_id, a.term_id, ta.subject_id;

A report_cards table with a maths_percent column is a second copy of the truth that goes stale the moment a mark is corrected. Computed from marks, the report card and the gradebook can never disagree.

Notice that the view groups by subject, not by section. Ayesha sat the first maths quiz in 7-A and the midterm in 7-B, after her transfer, and both count towards one maths result of 84.0. A report card keyed on the student's current class would have dropped the quiz.

What stops the computed report card from changing after it has been sent home is the publish lock, not a stored copy. Setting terms.results_published_at makes a trigger refuse any insert, update or delete on that term's marks. Lock, publish and override rules appear in 18 of the 46 sources, roughly half as many as report cards. A late correction becomes a deliberate act: an authorised user unpublishes the term, the corrected mark goes through audit_log, and the term is published again. If you issue PDF report cards, keep the file as a record of what was sent, never as the source of the numbers.

The same trigger checks that a score does not exceed max_score and that the student was in the assessment's section on the day it was held.

Fees: invoices, payments and a ledger

Fee structures appear in 35 of the 46 sources, invoices and receipts in 29, installments and concessions in 22, and a ledger with reconciliation in 19. The model is a ledger, for the same reason as stock: a paid boolean or a balance column on the student cannot answer what happened, and that is the only question a fee dispute ever asks.

create table fee_ledger (
  id                 bigint generated always as identity primary key,
  student_id         bigint not null references students(id),
  entry_type         text   not null
                     check (entry_type in ('charge', 'concession', 'payment',
                                           'refund', 'reversal')),
  amount_minor       bigint not null check (amount_minor <> 0),  -- + owed, - credit
  invoice_id         bigint references invoices(id),
  fee_head_id        bigint references fee_heads(id),
  payment_id         bigint references payments(id),
  reverses_entry_id  bigint unique references fee_ledger(id),
  approved_by        bigint references staff(id),
  posted_on          date   not null,
  posted_by          bigint not null references staff(id),
  note               text,
  check (case entry_type
           when 'charge'     then amount_minor > 0 and invoice_id is not null
                                  and fee_head_id is not null
           when 'concession' then amount_minor < 0 and invoice_id is not null
                                  and approved_by is not null and note is not null
           when 'payment'    then amount_minor < 0 and invoice_id is not null
                                  and payment_id is not null
           when 'refund'     then amount_minor > 0 and payment_id is not null
           else true
         end),
  check ((entry_type = 'reversal') = (reverses_entry_id is not null))
);

invoices is a document header (number, student, term, due date) with no amount column. Its amount is the sum of its ledger rows: charges per fee head, concessions, allocated payments and reversals. An installment plan is several invoices with different due dates. payments records the money received, and its allocation to an invoice is a payment row in the ledger.

Every amount is an integer in minor units (paisa, cents), never a float. The check makes each entry type carry its sign and references, so a concession cannot be posted without an approver and a reason; the planner's research requires permissions and an audit trail for concessions. Both fee_ledger and payments reject updates, deletes and truncation with a trigger.

Corrections are reversals. When Sara's cheque is returned unpaid, nothing is deleted:

insert into fee_ledger
  (student_id, entry_type, amount_minor, invoice_id, reverses_entry_id, posted_on, posted_by, note)
select student_id, 'reversal', -amount_minor, invoice_id, id, '2026-08-20', 3,
       'Cheque returned unpaid'
from fee_ledger where payment_id = 3 and entry_type = 'payment';

A trigger checks that a reversal mirrors its original exactly (same student, same invoice, opposite amount), and the unique key on reverses_entry_id stops an entry being reversed twice. The receipt still exists, the bounce is dated and attributed, and the invoice balance is correct again.

Balances are a view:

create view invoice_balances as
select i.id as invoice_id, i.number, i.student_id, i.due_on,
       coalesce(sum(f.amount_minor), 0) as outstanding_minor
from invoices i
left join fee_ledger f on f.invoice_id = i.id
group by i.id;

Keep one reconciliation query on a schedule. If it returns rows, a payment was received and not fully allocated:

select p.receipt_no, p.amount_minor,
       -coalesce(sum(f.amount_minor) filter (where f.entry_type = 'payment'), 0) as allocated
from payments p
left join fee_ledger f on f.payment_id = p.id
group by p.id
having p.amount_minor <> -coalesce(sum(f.amount_minor) filter (where f.entry_type = 'payment'), 0);

Billing per student is the simplest correct start. If the school invoices families, add a households table, but keep ledger rows per student so sibling concessions have somewhere to land.

The constraints that are load-bearing

  • No table except enrollments says which section a student is in. If you enforce one thing at review time, enforce this.
  • Enrollments cannot overlap, and their keys cannot be rewritten (exclusion constraint plus trigger).
  • One primary guardian per student, with permissions on the link, not the guardian.
  • The three timetable clash keys: section, teacher and room per term, weekday and period.
  • One attendance mark per student per session, one mark per student per assessment. Composite primary keys stop the duplicate row that double-counts an absence.
  • Marks and attendance only for enrolled students, checked against the date.
  • Published marks are locked; corrections to attendance and marks are audited.
  • The fee ledger is append-only, with sign rules per entry type and mirrored reversals.
  • Every status and code is a check constraint or a lookup table.

One check this example leaves to you: a term's dates should fall inside its academic year, which needs a trigger because a check constraint cannot look at another table.

Example queries

Run set search_path = school_example; first. Each query reads rows and views, never a stored total, and runs unchanged against the downloadable file.

Who is in 7-B today, with their primary contact:

select st.admission_no,
       p.family_name || ', ' || p.given_name   as student,
       gp.given_name || ' ' || gp.family_name  as primary_guardian,
       sg.relationship,
       c.value                                  as phone
from enrollments e
join sections sec      on sec.id = e.section_id
join academic_years ay on ay.id = sec.academic_year_id
join students st       on st.id = e.student_id
join people p          on p.id = st.person_id
left join student_guardians sg on sg.student_id = st.id and sg.is_primary
left join guardians g          on g.id = sg.guardian_id
left join people gp            on gp.id = g.person_id
left join contact_points c     on c.person_id = gp.id and c.kind = 'phone' and c.is_preferred
where ay.name = '2026-27' and sec.name = '7-B'
  and daterange(e.starts_on, e.ends_on, '[]') @> date '2026-10-18'
order by p.family_name, p.given_name;

Two rows: Ayesha Qureshi (primary contact her mother, Nadia) and Sara Raza (her father, Usman). Change the date to 2026-09-01 and Ayesha disappears from 7-B, because she was in 7-A then. A class_id column cannot answer the question for any day but today.

Attendance rate per student for the term:

select st.admission_no,
       count(*)                                          as marked,
       count(*) filter (where ac.counts_as_present)      as present,
       count(*) filter (where am.code = 'L')             as late,
       count(*) filter (where not ac.counts_as_present
                          and not ac.is_excused)         as absent,
       count(*) filter (where ac.is_excused)             as excused,
       round(100.0 * count(*) filter (where ac.counts_as_present)
             / count(*), 1)                              as rate_pct
from attendance_marks am
join attendance_sessions s on s.id = am.session_id
join attendance_codes ac   on ac.code = am.code
join students st           on st.id = am.student_id
join terms t               on t.name = 'Term 1'
join academic_years ay     on ay.id = t.academic_year_id and ay.name = '2026-27'
where s.session_date between t.starts_on and t.ends_on
group by st.admission_no
order by st.admission_no;

ADM-1001: 3 marked, 3 present (one late), rate 100.0. ADM-1003: 1 present, 1 absent, 1 excused, rate 33.3. Whether excused absences leave the denominator is school policy, and with is_excused stored as data it is a one-line change to the query.

The term report card, with letter grades:

select st.admission_no, sub.name as subject, r.percent,
       (select b.letter from grade_bands b
        where b.grade_scale_id = t.grade_scale_id and b.min_percent <= r.percent
        order by b.min_percent desc limit 1) as grade
from subject_results r
join terms t      on t.id = r.term_id
join students st  on st.id = r.student_id
join subjects sub on sub.id = r.subject_id
where t.id = 1
order by st.admission_no, sub.name;

ADM-1001: English 75.0 (B), Mathematics 84.0 (B). ADM-1003: English 85.0 (A), Mathematics 90.0 (A), because her exempt quiz is excluded rather than scored as zero. The letter comes from the term's scale, so changing next year's scale does not regrade this year.

Fee defaulters, oldest first:

select b.number, st.admission_no,
       p.given_name || ' ' || p.family_name     as student,
       b.due_on,
       date '2026-10-18' - b.due_on             as days_overdue,
       to_char(b.outstanding_minor / 100.0, 'FM999,999,990.00') as outstanding
from invoice_balances b
join students st on st.id = b.student_id
join people p    on p.id = st.person_id
where b.due_on < date '2026-10-18' and b.outstanding_minor > 0
order by days_overdue desc, b.outstanding_minor desc;

Two invoices, both 64 days overdue: Sara Raza owes 30,000.00 because her payment was reversed, and Bilal Qureshi owes 7,000.00 (30,000.00 tuition, less a 3,000.00 sibling concession, less a 20,000.00 part-payment). Ayesha's invoice is settled and does not appear.

Using this as a student database management system project in SQL

For a college or course project, the downloadable file is a working base, and the parts that earn marks are the ones most projects leave out. Demonstrate the rejections, not just the inserts: a transfer that keeps history, a register that refuses a child from another class, a double-booked teacher refused, marks locked on publishing, and a bounced payment reversed rather than deleted. Then show the four queries above with their outputs, and the ER diagram.

If the project must use PHP and MySQL, the tables port with type changes, but three PostgreSQL features do not. MySQL has no exclusion constraints, so the no-overlap rule for enrollments becomes a check inside the inserting transaction, after locking the student's row with select … for update. MySQL has no partial indexes, so "one primary guardian per student" needs a generated column that holds the student id only when is_primary is true, with a unique index on it. And check constraints are enforced only from MySQL 8.0.16; older versions parse and ignore them.

Why coding agents get this wrong

Ask an agent for a school management system and it reaches for the tutorial schema, because that is what its training data is full of: some mix of students.class_id, a guardian_phone on the student, a table of absences, a results table with a column per subject and a fee_paid flag. It is a specification failure, not a reasoning failure. Nothing in "build a school management system" says that a class is dated, that a guardian is a person with permissions, or that a payment can bounce.

Each omission is invisible in a demo and costly in a real school year. The first section change corrupts the registers. The first year end has no promotion path. The first separated family cannot give one parent pickup rights and not the other. The first corrected mark changes a report card that has already gone home. Practitioners on r/k12sysadmin describe the guardian version in SIS migrations: guardian data that landed in the wrong contact model and broke reports.

All of these are schema decisions made before the first screen exists, and fixing them later means migrating live data.


If you want the build order rather than the schema, how to build a school management system covers the sequence and the decisions that come before code. What a school management system includes is the module checklist, and what a student information system is draws the line between an SIS and an LMS and covers the full counted feature list. If you are not ready for a database, the school management Excel template applies the same enrollment and ledger rules across spreadsheet tabs.

And if you are pointing a coding agent at this, the School Management planner is the spec we hand ours: the guardian model, enrollment history, promotion, publish locks and the fee ledger written as requirements the agent has to build, so it cannot fall back to the tutorial schema. The school demos are three complete systems built from it that you can log into.