AI Computer Institute
Expert-curated CS & AI curriculum aligned to CBSE standards. A bharath.ai initiative. About Us

Python Dataclasses and Type Hints

📚 Programming & Coding⏱️ 19 min read🎓 Grade 9
✍️ AI Computer Institute Editorial Team Updated: August 2026 CBSE-aligned · Peer-reviewed · 19 min read
Content curated by subject matter experts with IIT/NIT backgrounds. All chapters are fact-checked against official CBSE/NCERT syllabi.

Open the scorecard app you use during an IPL match and look at one player's row: name, runs scored, balls faced, matches played, batting average. Every player has the exact same set of fields, in the exact same order, every single time. That regularity is not an accident — it is exactly the kind of structure that programming languages are built to capture. In this chapter you will discover why Python's plain tools (variables, then dictionaries, then ordinary classes) each fall short of capturing that structure cleanly, and how two features working together — type hints and the @dataclass decorator — solve the problem completely. By the end, you will be able to read and write compact, self-documenting classes that CBSE Class IX practical exams and real Python codebases both use constantly.

Starting point: one player, tracked with plain variables

Suppose you want to store data for an IPL batter — his name, total runs, and matches played. (Throughout this chapter we will use the Indian batter Suryakumar Yadav's name with round, illustrative numbers — 12000 runs, 254 matches — chosen purely to make the arithmetic easy to trace, not as a claim about his real career figures, which change with every match he plays.) The most obvious approach is separate variables:

name = "Suryakumar Yadav"
runs = 12000
matches = 254

This works for one player. But an IPL squad has roughly 25 players, and a full tournament dataset might track ten squads. You would need name1, runs1, matches1, name2, runs2, matches2, ... — completely unmanageable, and there is nothing in the code that groups name1 with runs1 as belonging to the "same player." The variables are just floating labels with no container.

Second attempt: a dictionary

Dictionaries fix the grouping problem — one dictionary can hold all of a player's fields together:

player = {"name": "Suryakumar Yadav", "runs": 12000, "matches": 254}

This is better — one variable, one player. But dictionaries have a structural weakness: nothing stops you from misspelling a key, and nothing tells you in advance which keys are even supposed to exist. Try this:

print(player["avg"])

Running this raises KeyError: 'avg' — not because the idea of an average is wrong, but because no key named "avg" was ever created, and Python has no way to warn you about this until the line actually executes. If a teammate writing code that touches your player dictionary later needs to know exactly which keys exist and what type each one holds, they have to go hunting through the code that built the dictionary — there is no fixed blueprint anywhere. This is the core weakness dictionaries have for representing "the same kind of thing, many times": the structure lives only in your head, not in the code.

Third attempt: an ordinary class

A class is Python's tool for describing a fixed blueprint — a template that says "every object of this kind has exactly these attributes." You have already met classes as blueprints for objects; a plain class for our player looks like this:

class Player:
    def __init__(self, name, runs, matches):
        self.name = name
        self.runs = runs
        self.matches = matches

p1 = Player("Suryakumar Yadav", 12000, 254)
print(p1)

This solves the "what fields exist" problem — the __init__ method spells it out. But two irritations show up immediately. First, printing p1 gives:

<__main__.Player object at 0x10537a120>

The exact hexadecimal number after at will differ every time you run this on your own machine — it is the object's memory address, and Python's default __repr__ method simply reports where the object lives in memory, not what data it holds. That is close to useless for debugging: you cannot tell from this output whether p1 represents Suryakumar Yadav or an empty player with no runs scored.

Common misconception #1: many students assume that because two Player objects were built from identical arguments, comparing them with == will say they are equal — the same way "5" == "5" or 7 == 7 is true. Test it:

p2 = Player("Suryakumar Yadav", 12000, 254)
print(p1 == p2)

This prints False. The reason is that Python's default == for a plain class does not compare the objects' field values at all — it compares their identity, i.e., whether they are literally the same object sitting at the same memory address (the same check is performs). Since p1 and p2 are two separate objects created by two separate calls to Player(...), they live at different addresses, so == reports False even though every field matches. To fix this in a plain class, you would have to write your own __eq__ method by hand — along with your own __repr__, and your own __init__, all of which repeat the field names name, runs, and matches over and over. This repetition is exactly the itch that dataclasses were built to scratch.

What a type hint actually is

Before fixing the class, you need one more ingredient: type hints. A type hint is a piece of syntax that lets you write, next to a variable or parameter, what kind of value it is expected to hold — without changing how the program runs. The syntax is name: type:

def total_runs(a: int, b: int) -> int:
    return a + b

Read this as: "a is expected to be an int, b is expected to be an int, and the function is expected to return an int." This is documentation that both humans and certain tools (like the mypy type-checker, or the auto-complete in an editor such as VS Code) can read and act on.

Common misconception #2: because this looks similar to type declarations in languages like Java or C++, many students assume Python will refuse to run the function if you pass the wrong type — the way a strictly typed language would refuse to compile. It will not. Type hints in Python are advisory, not enforced, at runtime. Prove it:

print(total_runs("5", "3"))

This prints 53, not 8. Nothing crashes. Python happily accepts two strings where the hint promised two integers, and because + on strings means concatenation, "5" + "3" glues the characters together into the string "53". The type hint a: int was never checked by the Python interpreter — it is a note for readers and for optional external tools, not a runtime gate. This is a genuinely important fact to internalize before touching dataclasses, because dataclasses rely on type hints for their machinery, and it is tempting to assume that reliance means enforcement. It does not.

The @dataclass decorator: type hints made structural

A dataclass is a class where you declare each field as a type-hinted line, and a decorator called @dataclass reads those lines and automatically writes the repetitive methods for you — __init__, a readable __repr__, and a field-by-field __eq__ — so you never type them by hand. Import it from the built-in dataclasses module and rewrite Player:

from dataclasses import dataclass

@dataclass
class PlayerDC:
    name: str
    runs: int
    matches: int

That is the entire class body — three type-hinted lines, no __init__, no self.name = name boilerplate. Watch what changes:

d1 = PlayerDC("Suryakumar Yadav", 12000, 254)
print(d1)

This prints:

PlayerDC(name='Suryakumar Yadav', runs=12000, matches=254)

a readable, field-by-field summary — no memory address in sight, because @dataclass generated a proper __repr__ for you by reading the three type-hinted fields. Now equality:

d2 = PlayerDC("Suryakumar Yadav", 12000, 254)
print(d1 == d2)

This prints True. The generated __eq__ compares name to name, runs to runs, and matches to matches, field by field — exactly the behavior a beginner intuitively expects from ==, and exactly what the plain class in the previous section failed to give you for free. The type hints are doing real, load-bearing work here: @dataclass looks at the class body, collects every line written as fieldname: type, and uses that exact list — in that exact declared order — to build __init__'s parameter list, __repr__'s output, and __eq__'s comparison. Remove the type hint from a line and @dataclass will not recognize it as a field at all.

@dataclass reads fields, writes methods class PlayerDC: name: str runs: int matches: int 3 type-hinted fields @dataclass decorator __init__(self, name, runs, matches) __repr__ (readable "PlayerDC(name=...)") __eq__ (compares name, runs, matches) field order in the class body fixes parameter order and comparison order

Worked example: computing a batting average with dataclass fields

Type-hinted fields are ordinary attributes, so you can compute with them exactly as you would with any object's attributes. Suppose you want d1's batting average — runs divided by matches:

average = d1.runs / d1.matches
print(average)

Trace this by hand before running it: d1.runs is 12000, d1.matches is 254, and / in Python always produces a full-precision floating-point result, so 12000 / 254 prints as 47.24409448818898 — every one of those digits is Python's actual float representation, not a rounded approximation. For a scoreboard you would not want fourteen decimal places, so round it:

print(round(average, 2))

This prints 47.24. round(x, 2) keeps two digits after the decimal point, which is the conventional precision for a batting average.

The mutable-default trap

Now extend the idea from one player to a whole squad. It seems natural to add a list field for player names:

@dataclass
class Team:
    name: str
    players: list = []

Run this and Python refuses even to let you define the class — it raises, at class-definition time, before you have created a single Team object:

ValueError: mutable default <class 'list'> for field players is not allowed: use default_factory

Why does @dataclass block this so aggressively? Because a default value written directly after = is created exactly once, when the class body runs — not once per object. If Python allowed players: list = [], every Team you ever created without explicitly passing a players argument would share the same list object. Adding a player to the Chennai Super Kings' squad would silently also add them to the Mumbai Indians' squad, because both team objects would be pointing at the identical list in memory. Rather than let you discover this bug the hard way during a match simulation, @dataclass refuses to run at all. The fix is field(default_factory=list), which tells @dataclass: "call list() fresh, separately, every time a new object is built":

from dataclasses import dataclass, field

@dataclass
class Team:
    name: str
    players: list = field(default_factory=list)

csk = Team("Chennai Super Kings")
mi = Team("Mumbai Indians")
csk.players.append("Ruturaj Gaikwad")
print(csk.players)
print(mi.players)

This now prints ['Ruturaj Gaikwad'] for csk.players and [] for mi.players — two genuinely separate list objects, because default_factory=list called list() once for csk and again, independently, for mi. This is a real bug that catches even experienced programmers, and CBSE practical questions on dataclasses will often specifically test whether you know why a bare mutable default is rejected.

Locking a record: frozen=True

Sometimes you want an object to behave like a printed scorecard — once written, it should not change. Passing frozen=True to the decorator makes every field read-only after construction:

@dataclass(frozen=True)
class FrozenPlayer:
    name: str
    runs: int

fp = FrozenPlayer("Virat Kohli", 8000)
fp.runs = 9000

The last line raises dataclasses.FrozenInstanceError: cannot assign to field 'runs'. This is useful whenever a value should be a fixed record of something that already happened — a completed match's final score, a submitted exam's final marks — rather than a value that legitimately keeps changing.

Sorting squads with order=True

Passing order=True makes @dataclass generate comparison methods (<, <=, >, >=) as well, which lets you hand a list of objects straight to sorted(). The comparison works by treating each object as a tuple of its fields, in declared order, and comparing those tuples exactly the way Python compares tuples — field by field, left to right, only moving to the next field when the current one ties. This means field declaration order controls sort order, which is worth seeing twice, with the same three players, in two different field orders:

@dataclass(order=True)
class P1:
    runs: int
    name: str

players1 = [P1(45, "Zaheer"), P1(80, "Arjun"), P1(45, "Aakash")]
print(sorted(players1))

This prints [P1(runs=45, name='Aakash'), P1(runs=45, name='Zaheer'), P1(runs=80, name='Arjun')]. Sorting compares runs first — the two players tied on 45 runs (Zaheer and Aakash) are placed before the player with 80 runs, and between the two tied on runs, the second field, name, breaks the tie alphabetically ("Aakash" before "Zaheer").

Now swap only the field order in the class definition, changing nothing else:

@dataclass(order=True)
class P2:
    name: str
    runs: int

players2 = [P2("Zaheer", 45), P2("Arjun", 80), P2("Aakash", 45)]
print(sorted(players2))

This prints [P2(name='Aakash', runs=45), P2(name='Arjun', runs=80), P2(name='Zaheer', runs=45)] — alphabetical by name first, because name is now the first field in the tuple comparison, and runs only matters to break ties among players who share the same name (which does not happen here, so it never even gets consulted). The underlying data is identical in both examples; only the order in which you typed the field declarations changed, and that alone completely changed what "sorted" means. This is precisely why field order in a dataclass is not cosmetic — it is part of the class's behavior.

Why this pairing matters for CBSE and beyond

Since classes as blueprints for objects are already familiar to you, dataclasses are best understood as the same idea with the repetitive parts automated: you declare what a record contains, using type hints to say what kind of value each field holds, and @dataclass writes the how — construction, printing, and comparison — for you. CBSE Class IX Computer Science practical work increasingly expects you to design small record-like classes (a student's roll number, name, and marks; a book's title, author, and price), and dataclasses let you express that design in three or four lines instead of ten, while making the field types explicit for anyone reading your code afterward. The core discipline to carry forward is: type hints describe intent but are not checked while the program runs, @dataclass uses exactly the fields you type-hint and in exactly the order you write them, and features like default_factory, frozen=True, and order=True exist to solve specific, predictable bugs — a shared mutable default, an accidentally editable record, and an ambiguous sort — rather than being decorative options.

Active recall practice

  1. A classmate writes @dataclass class Book: title: str; price: float = 0 and then creates b = Book("Panchatantra"). What will print(b) show, and why does price appear even though it was not passed as an argument? Trace it before checking: the default 0 applies, so it prints Book(title='Panchatantra', price=0).
  2. Without running it, predict the output of: @dataclass class Score: subject: str; marks: int, then Score("Maths", 90) == Score("Maths", 90). Justify your answer using what generates @dataclass's __eq__, then verify by tracing that both objects have identical subject and marks, so the field-by-field comparison returns True.
  3. Explain, in your own words, why @dataclass class Quiz: questions: list = [] fails immediately, while questions: list = field(default_factory=list) succeeds — refer specifically to when each version's list is created.
  4. Two dataclasses hold the same three fields — city: str, population: int, area_km2: float — but one declares them in that order and the other declares population first. With order=True on both, explain why sorted() on a list of each could return the cities in a different sequence.
  5. A student writes a function def add_marks(a: int, b: int) -> int: return a + b and then calls add_marks("10", "20"). State exactly what prints, and explain why the type hints did not prevent it.

Summary

Plain variables cannot group related data; dictionaries group data but enforce no fixed structure and fail silently with a KeyError on any typo; ordinary classes fix the structure problem but force you to hand-write __init__, a useful __repr__, and a value-based __eq__ yourself. Type hints (name: type) let you declare a field's intended type as documentation that is never enforced at runtime. The @dataclass decorator reads a class's type-hinted fields, in the order they are declared, and automatically generates __init__, __repr__, and __eq__ from them — turning three or more lines of boilerplate per feature into zero. field(default_factory=...) gives every new object its own fresh mutable value instead of one dangerously shared default; frozen=True makes fields read-only after construction; order=True generates tuple-style comparisons where field declaration order determines sort priority. Together, type hints and @dataclass let you describe a record once, precisely, and get correct, debuggable behavior for free.

← Design Patterns: Factory, Singleton, ObserverConvolutional Neural Networks for Images →

Found this useful? Share it!

📱 WhatsApp 🐦 Twitter 💼 LinkedIn