Student Grade Manager

Manage student records and calculate statistics using dictionaries, OOP, and file persistence.

Part of the free Python course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

Project Overview

A grade manager is the small, honest version of the software every school actually runs. You keep one record per student, you attach scores to that record as work comes in, and at any moment you can ask the program two questions: what is this student's average, and what letter does that average earn? Reports, exports and class-wide statistics are all built on top of those two answers.

When it works, you run it in a terminal and get a numbered menu that loops until you quit. Type 1 and a name to create a student, type 2 and a number to record a score, type 3 to see every student's average and letter grade in one list, and 4 to print one student's full report. Nothing is written to disk yet — the records live in memory for as long as the program runs, which is precisely the limitation the enhancement section teaches you to remove.

A teacher tracking a single class is the obvious user, but the shape is far more general. A coach recording race times, a warehouse tracking pick accuracy, a gym logging lifts — any time you have named things that accumulate numbers and need summarising, this is the program you are writing.

Goal: Store students and calculate averages.

Concepts: Dictionaries, OOP, file persistence, exception handling.

Key Features:

Core Concepts

Five ideas carry this whole project. If you understand each one before you start typing, the starter code below will read as obvious rather than as magic.

A dictionary is a lookup index, not a list

The program stores students in students = {}, a dictionary whose keys are names and whose values are Student objects. A list would force you to scan every entry to find "Bob"; a dictionary jumps straight to him no matter how many students you have, and name in students answers "do we know this person?" in one expression. The price you pay is that the key must be unique, which becomes a real bug you will meet later on this page.

A class bundles data with the behaviour that belongs to it

You could keep names in one list and scores in another and hope the positions stay aligned. A Student class removes that hope: the name and the grades live in the same object, and the methods that interpret those grades live right next to them. When you delete a student, their scores go with them automatically, because there is nothing else holding a reference to them.

Compute values, do not store them

Notice that no self.average attribute is ever assigned. The average is a method that recalculates from self.grades every time you call it. A stored average would be correct the moment you wrote it and wrong the moment someone appended another score, and that class of bug — two fields that are supposed to agree and quietly stop agreeing — is one of the most common in beginner projects.

Guard clauses keep invalid data out at the door

add_grade refuses anything outside 0 to 100, and average refuses to divide when the grade list is empty. Both checks sit inside the class rather than in the menu, so they hold no matter who calls them — the menu today, a CSV importer tomorrow. Validating at the source is the difference between a rule and a suggestion.

Exceptions belong where the outside world touches your program

input() hands you whatever the human typed, and humans type "ninety". Wrapping the conversion in try/except ValueError turns a crash into a message and keeps the menu loop alive. You catch the one specific exception you expect, not a bare except:, because swallowing every error hides the bugs you actually need to see.

Starter Code

This is a command-line app that uses input() for user interaction. The browser demo below shows a simplified version. For the full interactive experience, copy the code below and run it in your local Python environment (IDLE, VS Code, or terminal). Jump to the Browser Demo.

Here's a functional starting version of your app. Copy it to your local Python editor and run it.

Read it as four pieces stacked in order. First the Student class, which knows one student's name and scores and can summarise them. Then a single empty dictionary that holds every student the session knows about. Then a menu() function whose only job is printing the options — kept separate so the loop below stays readable. And finally the while True loop that reads a choice and dispatches to the matching branch. That last piece is the whole application; everything above it is the machinery it drives.

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []
    
    def add_grade(self, grade):
        if 0 <= grade <= 100:
            self.grades.append(grade)
            print(f"✓ Added grade {grade} for {self.name}")
        else:
            print("Grade must be between 0 and 100!")
    
    def average(self):
        return sum(self.grades) / len(self.grades) if self.grades else 0
    
    def get_letter_grade(self):
        avg = self.average()
        if avg >= 90: return "A"
        elif avg >= 80: return "B"
        elif avg >= 70: return "C"
        elif avg >= 60: return "D"
        else: return "F"

students = {}

def menu():
    print("\n📚 Student Grade Manager")
    print("1. Add Student")
    print("2. Add Grade")
    print("3. View All Students")
    print("4. View Student Report")
    print("5. Exit")

while True:
    menu()
    choice = input("\nChoice: ")
    
    if choice == "1":
        name = input("Student name: ")
        students[name] = Student(name)
        print(f"✓ Added {name}")
    
    elif choice == "2":
        name = input("Student name: ")
        if name in students:
            try:
                grade = float(input("Grade: "))
                students[name].add_grade(grade)
            except ValueError:
                print("Please enter a valid number!")
        else:
            print("Student not found!")
    
    elif choice == "3":
        if not students:
            print("No students yet!")
        else:
            print("\n📊 All Students:")
            for s in students.values():
                print(f"  {s.name}: {s.average():.1f} ({s.get_letter_grade()})")
    
    elif choice == "4":
        name = input("Student name: ")
        if name in students:
            s = students[name]
            print(f"\n📈 Report for {s.name}")
            print(f"Grades: {s.grades}")
            print(f"Average: {s.average():.1f}")
            print(f"Letter Grade: {s.get_letter_grade()}")
        else:
            print("Student not found!")
    
    elif choice == "5":
        print("Goodbye!")
        break

The mistake this structure avoids is scattering grade logic through the menu. Every branch asks the object a question and prints the answer — none of them adds up scores or decides a letter themselves. That is why option 3 and option 4 can never disagree about Alice's average: they call the same method. The moment you start computing a total inside a menu branch "just this once", you have created two sources of truth that will drift apart.

🚀 How to Run Locally:

How Each Piece Works

Now take the starter code apart. Each piece below is lifted straight from it, with the reasoning that shaped it and the mistake it is defending against.

1. The constructor gives every student an empty list

A new student has no grades yet, so __init__ assigns a fresh empty list. Creating it here rather than at class level matters: if you wrote grades = [] as a class attribute instead, every student would share one list and Alice's scores would show up on Bob's report.

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []

The pitfall this avoids is the shared-mutable-default bug, and it is genuinely hard to spot because nothing errors — the numbers are just wrong, and only once you have a second student.

2. Validation lives in add_grade, not in the menu

0 <= grade <= 100 is a chained comparison — Python reads it as one range test, exactly the way you would say it out loud. The bounds are inclusive, so a perfect 100 and a zero for missing work both get through, and anything else is rejected with a message rather than silently stored.

    def add_grade(self, grade):
        if 0 <= grade <= 100:
            self.grades.append(grade)
            print(f"✓ Added grade {grade} for {self.name}")
        else:
            print("Grade must be between 0 and 100!")

The common mistake here is writing if grade > 0 and grade < 100, which quietly refuses both a zero and a perfect score — two of the values a real gradebook needs most. The second mistake is putting this check in the menu branch instead, where a future CSV importer would bypass it entirely.

3. average() refuses to divide by nothing

The conditional expression on the end is the whole point of this method. A student who has been added but not yet graded has an empty list, and len(self.grades) is then zero. Returning 0 instead keeps the "view all students" report printable on day one of term.

    def average(self):
        return sum(self.grades) / len(self.grades) if self.grades else 0

Drop the guard and the program dies the first time anyone views a brand-new student:

ZeroDivisionError: division by zero

Note that if self.grades tests the list's truthiness — an empty list is falsy — which is more idiomatic than if len(self.grades) > 0 and means the same thing. Returning 0 is a design decision, not the only answer; some gradebooks return None so an ungraded student is visibly different from one who scored zero on everything.

4. The letter-grade ladder only works top-down

Each branch is checked in order and the first true one wins, so the thresholds must descend. Because 95 is also greater than 80, 70 and 60, a ladder written the other way round would match on the very first test every time.

    def get_letter_grade(self):
        avg = self.average()
        if avg >= 90: return "A"
        elif avg >= 80: return "B"
        elif avg >= 70: return "C"
        elif avg >= 60: return "D"
        else: return "F"

Feeding it the boundary values shows the cut-offs are inclusive at the bottom of each band — exactly 90 is an A, and 89.9 is not:

  100 -> A
   90 -> A
 89.9 -> B
   80 -> B
   70 -> C
   60 -> D
 59.9 -> F
    0 -> F

Now reverse the ladder so it starts at 60 and climbs. Nothing raises an error; every student simply becomes a D:

95 -> D
85 -> D
75 -> D
65 -> D

That is the mistake worth remembering: an elif chain in the wrong order is not a crash, it is a wrong answer delivered confidently. Test the boundaries, not just the middle of each band.

5. The menu loop compares strings, and keys must be unique

input() always returns a string, even when the user types a digit. That is why every branch compares against "1" in quotes. Compare against the integer 1 and no branch ever matches — the menu just reprints itself forever with no error to explain why.

choice = "1"          # input() always hands back a string
print(choice == 1)    # comparing a str to an int
print(choice == "1")  # comparing a str to a str
False
True

The dictionary has its own trap. Option 1 assigns students[name] = Student(name) unconditionally, so adding a name that already exists replaces the old record — grades and all — without a word of warning:

Before: [95]
After:  []
Students on file: 1

Guarding it is two lines — check if name in students first and refuse, or ask the user to confirm. The deeper lesson is that a name is a poor primary key: two students really can be called Sam Patel, which is why school systems issue ID numbers. Options 2 and 4 already do the membership check before indexing, and skipping it is what produces the KeyError in the next section.

🎮 Browser Demo (Simplified Version)

This browser version demonstrates the core logic without user input. Click Run to see it work!

The class is byte-for-byte the same as the one in the starter code. What changes is the driver: instead of a menu asking a human for values, the script hard-codes three students and three scores each, so the whole run finishes on its own. That is a useful habit well beyond this page — when you want to check that your logic is right, drive it with fixed inputs rather than by typing at a prompt, because a fixed input gives you the same answer every time and a typed one does not.

Watch what the three averages tell you. Alice's 95, 87 and 92 come to 91.3 — an A. Charlie's 88, 90 and 86 come to 88.0, which is a B despite two of his three scores beating one of Alice's. Averaging discards that detail on purpose, and understanding what a summary throws away is half of knowing when to trust it.

class Student:
    def __init__(self, name):
        self.name = name
        self.grades = []
    
    def add_grade(self, grade):
        if 0 <= grade <= 100:
            self.grades.append(grade)
            print(f"✓ Added grade {grade} for {self.name}")
        else:
            print("Grade must be between 0 and 100!")
    
    def average(self):
        return sum(self.grades) / len(self.grades) if self.grades else 0
    
    def get_letter_grade(self):
        avg = self.average()
        if avg >= 90: return "A"
        elif avg >= 80: return "B"
        elif avg >= 70: return "C"
        elif avg >= 60: return "D"
        else: return "F"

# Demo: Create students and add grades
print("📚 Student Grade Manager Demo\n")

# Create students dictionary
students = {}

# Add students
print("Adding students...")
students["Alice"] = Student("Alice")
students["Bob"] = Student("Bob")
students["Charlie"] = Student("Charlie")
print(f"✓ Added {len(students)} students\n")

# Add grades
print("Adding grades...")
students["Alice"].add_grade(95)
students["Alice"].add_grade(87)
students["Alice"].add_grade(92)

students["Bob"].add_grade(78)
students["Bob"].add_grade(85)
students["Bob"].add_grade(82)

students["Charlie"].add_grade(88)
students["Charlie"].add_grade(90)
students["Charlie"].add_grade(86)

# Display all students
print("\n📊 All Students:")
for s in students.values():
    print(f"  {s.name}: {s.average():.1f} ({s.get_letter_grade()})")

# Detailed report for one student
print("\n📈 Detailed Report for Alice:")
alice = students["Alice"]
print(f"  Grades: {alice.grades}")
print(f"  Average: {alice.average():.1f}")
print(f"  Letter Grade: {alice.get_letter_grade()}")

What it prints

Running the demo above produces exactly this:

📚 Student Grade Manager Demo

Adding students...
✓ Added 3 students

Adding grades...
✓ Added grade 95 for Alice
✓ Added grade 87 for Alice
✓ Added grade 92 for Alice
✓ Added grade 78 for Bob
✓ Added grade 85 for Bob
✓ Added grade 82 for Bob
✓ Added grade 88 for Charlie
✓ Added grade 90 for Charlie
✓ Added grade 86 for Charlie

📊 All Students:
  Alice: 91.3 (A)
  Bob: 81.7 (B)
  Charlie: 88.0 (B)

📈 Detailed Report for Alice:
  Grades: [95, 87, 92]
  Average: 91.3
  Letter Grade: A

Two details in that output are worth pausing on. The averages read 91.3 and 88.0 rather than 91.33333333333333 and 88.0 because of the :.1f format specifier in the f-string. Remove it and the report turns into a wall of digits:

Average: 91.33333333333333
Average: 91.3

And 88.0 keeps its trailing zero for the same reason, which is what you want in a column of numbers. The other detail is students.values() in the loop — you iterate the Student objects, not the keys, because each object already carries its own name. Looping over the dictionary directly would hand you strings and s.average() would fail.

Common Errors

These are the five messages you are most likely to meet while building this project. Read the last line of a traceback first — it names the error type and the value that caused it, and that pair is usually enough to find the bug without reading anything above it.

ValueError: could not convert string to float: 'ninety'

Raised by float(input("Grade: ")) when the user types words, leaves the prompt blank, or adds a stray percent sign. The starter code already wraps that conversion in try/except ValueError, which is why option 2 prints "Please enter a valid number!" instead of dying. If you add another numeric prompt later — a weighting, a subject count — it needs the same wrapper; the first one does not protect it.

KeyError: 'Dave'

Raised by students["Dave"] when no such key exists — a typo, trailing whitespace, or a difference in capitalisation is enough, because "dave" and "Dave" are different keys. The fix is the if name in students check the starter code already performs. A tidier long-term answer is to normalise names on the way in with .strip() so an accidental trailing space never creates a phantom second student.

ZeroDivisionError: division by zero

Raised inside average() if you remove the if self.grades else 0 guard and then view a student who has no scores. It also comes back when you add class-wide statistics later and divide by the number of students before anyone has been added — the same bug in a new place. Any division whose denominator is a length needs to survive that length being zero.

AttributeError: 'str' object has no attribute 'average'

Raised when you loop with for s in students: instead of for s in students.values():. Iterating a dictionary yields its keys, so s is the name string, not the Student object. Use .values() for the objects, .items() when you want both.

TypeError: unsupported format string passed to method.__format__

Raised by f"{s.average:.1f}" — the parentheses are missing, so you handed the format specifier the method object itself rather than the number it returns. Write f"{s.average():.1f}". Without a format specifier the same mistake does not raise at all; it prints something like a bound method repr, which is even more confusing, so it is worth training your eye to spot the missing parentheses.

The bug that produces no error at all

Worth naming separately: if your menu keeps looping and nothing you type does anything, you are almost certainly comparing choice to an integer instead of a string, or your elif chain has no final else to say "unknown option". Silence is a symptom, not a sign of health. Add an else branch that prints the choice it did not recognise and the cause becomes obvious in one run.

Enhancement Ideas 🚀

Add these one at a time and run the program after each. Five half-finished features teach you far less than one that works, and the order below is deliberate — persistence and multiple subjects both change the shape of your data, so doing them early saves rewriting the rest.

1. CSV Export

Export student data to CSV format using the csv module for easy import into Excel.

Write a header row first, then one row per student with name, average and letter grade. Pass newline="" when you open the file — without it you get a blank line between every row on Windows. Building the rows from average() and get_letter_grade() rather than from stored fields keeps the export honest.

import csv

rows = [("Alice", 91.3, "A"), ("Bob", 81.7, "B"), ("Charlie", 88.0, "B")]
with open("grades.csv", "w", newline="") as f:
    writer = csv.writer(f)
    writer.writerow(["name", "average", "letter"])
    writer.writerows(rows)

The file then contains:

name,average,letter
Alice,91.3,A
Bob,81.7,B
Charlie,88.0,B

2. Multiple Subjects

Track grades for different subjects (Math, English, Science) and calculate subject-specific averages.

This is the one change that touches everything, because self.grades stops being a list and becomes a dictionary of subject to list of scores. setdefault creates the list the first time a subject appears, so you never have to check whether it exists, and .get(subject, []) on the way out means asking for a subject the student has never taken returns nothing rather than raising.

    def add_grade(self, subject, grade):
        self.grades.setdefault(subject, []).append(grade)

    def subject_average(self, subject):
        scores = self.grades.get(subject, [])
        return sum(scores) / len(scores) if scores else 0

Giving Alice two Math scores and one English score, then asking for a subject she has not taken, prints:

Math: 91.0
English: 78.0
History: 0.0

That History: 0.0 is the design question from earlier coming back. A subject with no scores and a subject where the student scored zero look identical, which is fine for a homework project and unacceptable in a real gradebook.

3. Grade Statistics

Calculate class statistics like median, mode, highest, and lowest grades.

The standard library's statistics module already has mean, median and mode, so you do not need to write them. Median is the one worth adding first: a single failing student drags the mean down hard, and comparing mean against median tells you whether the class is genuinely struggling or whether one outlier is doing the damage.

import statistics

averages = [91.3, 81.7, 88.0]
print(f"Class average: {statistics.mean(averages):.1f}")
print(f"Median:        {statistics.median(averages):.1f}")
print(f"Highest:       {max(averages):.1f}")
print(f"Lowest:        {min(averages):.1f}")
Class average: 87.0
Median:        88.0
Highest:       91.3
Lowest:        81.7

Guard this feature against an empty class before you ship it — max() on an empty list raises, and so does the mean.

4. Persistent Storage

Save and load student data using JSON files so data persists between sessions.

This is the enhancement that turns a demo into a tool. JSON cannot store a Student object directly, so you convert each one to a plain dictionary on the way out and rebuild the objects on the way in. Save after every change rather than only on exit, so a crash costs you nothing.

import json

data = {"Alice": [95, 87, 92], "Bob": [78, 85, 82]}
with open("grades.json", "w") as f:
    json.dump(data, f, indent=2)

with open("grades.json") as f:
    loaded = json.load(f)

print(type(loaded).__name__, list(loaded))
print(loaded["Alice"])
dict ['Alice', 'Bob']
[95, 87, 92]

The trap is right there in the first line of output: json.load gives you back a plain dict, not Student objects. Call .average() on what it returns and you get an AttributeError. Loop over the loaded data and construct a Student for each entry. Handle the first run too: the file will not exist yet, so catch FileNotFoundError and start with an empty dictionary.

One more thing to know before it bites you: never name your own file json.py or csv.py. Python will import your file instead of the standard library module and the error you get will make no sense at all.

5. Search & Filter

Add search functionality and filter students by grade range or letter grade.

A list comprehension over students.values() does the whole job in one line — keep the students whose average falls in a range, or whose letter grade matches. Lowercase both sides when you search by name so that typing "alice" finds Alice, and always handle the no-matches case explicitly rather than printing an empty list, which reads to a user like the program broke.

6. Weighted Grades

Once subjects work, make a final exam count more than a quiz. Store each score with a weight and replace the plain mean with a weighted one: multiply each score by its weight, sum those, and divide by the sum of the weights — not by the number of scores. Getting that denominator wrong is the classic weighted-average bug, and the symptom is averages that quietly exceed 100.

Next Steps

Work down this list in order. Each step leaves you with a program that still runs, which is the point — you should never be more than one working version away from something you can demonstrate.

Test the awkward cases deliberately rather than waiting to meet them by accident: a student with no grades at all, a grade of exactly 90, a name typed with a trailing space, a menu choice of 7, and the very first run when no save file exists. Those five inputs find most of the bugs in a program this size.

You will know the project is finished when you can quit the program, restart it, and see the same students and the same averages you left behind — and when nothing a user can type at the menu produces a traceback. That combination — persistence plus surviving bad input — is what separates a script from a tool.

Related lessons