All 57 lessons

Lesson 55 of 57 · Writing Professional Python

Dataclasses, Type Hints and Clean Code

By Skillancy Editorial Team

Run the code in the free Python Compiler or on your own computer. Lessons that read files use the FreshBasket practice data: download the dataset (ZIP).

When a script grows beyond a hundred lines, the main problem stops being "does it run?" and becomes "can anyone understand it, change it and trust it?" Professional Python is not about cleverness. It is about making intent obvious: naming things well, declaring what kind of data a function expects, grouping related fields into a single object and writing small functions that do one thing.

This lesson gives you four tools that do most of that work. Type hints document what a function takes and returns, and let tools catch mistakes before the code runs. Dataclasses turn a plain class into a tidy record with almost no code. Enums replace error-prone magic strings such as "Delivred". Docstrings (with runnable examples) explain behaviour and double as tests. We finish with a worked refactor: a messy function transformed step by step into clean code whose output we prove is unchanged.

We keep modelling FreshBasket: products, orders and stores become typed objects instead of loose dictionaries and DataFrame rows.

Worked examples

Example 1: Type hints on functions

Question: How do we say what a function expects and returns?

def margin_percent(price: float, cost: float) -> float:
    """Gross margin as a percentage of price."""
    return (price - cost) / price * 100

def find_product(product_id: str, catalogue: dict[str, float]) -> float | None:
    """Return the price, or None when the product is unknown."""
    return catalogue.get(product_id)

print(round(margin_percent(48, 36.31), 1))
print(find_product("P001", {"P001": 48.0}), find_product("P999", {"P001": 48.0}))
print(margin_percent.__annotations__)

Output

24.4
48.0 None
{'price': <class 'float'>, 'cost': <class 'float'>, 'return': <class 'float'>}

How it works: After each parameter, : type states the expected type, and -> type states the return type. Collections are parameterised (dict[str, float] is a dictionary from strings to floats), and float | None means "a float or None". Hints are documentation that tools can check: your editor uses them for autocompletion and warnings, and a type checker such as mypy or pyright can find mistakes without running the code. The annotations are stored in __annotations__.

Reading the result: The margin of a ₹48 product costing ₹36.31 is 24.4%. Looking up an unknown ID returns None, which is why the return type includes None: callers are warned to handle it.

Example 2: Hints are not enforced at runtime

Question: What happens if we ignore the hints?

def double(quantity: int) -> int:
    return quantity * 2

print(double(4))
print(double("ab"))          # wrong type, but Python does not object
print(double(2.5))           # a float is not an int either

Output

8
abab
5.0

How it works: Python does not check hints when the program runs. The hint says int, yet a string and a float were accepted without a murmur, and a string even "worked" ("ab" * 2 repeats it), which is arguably worse than an error because the bug stays hidden. The hints pay off in two ways: editors and type checkers such as mypy flag these calls before you run, and readers instantly know what the function wants. When you need real runtime validation of data (for example from a file or an API), validate explicitly, as the dataclass examples below do.

Reading the result: double("ab") returned abab and double(2.5) returned 5.0, both outside the declared type, with no error. Only a type checker or explicit validation would have caught them.

Example 3: A first dataclass

Question: How do we represent a product without writing boilerplate?

from dataclasses import dataclass
import pandas as pd

@dataclass
class Product:
    product_id: str
    name: str
    category: str
    price: float
    cost: float

    @property
    def margin_pct(self) -> float:
        return (self.price - self.cost) / self.price * 100

milk = Product("P007", "Full Cream Milk 1L", "Dairy", 56.0, 47.0)
print(milk)
print(milk.name, round(milk.margin_pct, 1))
print(milk == Product("P007", "Full Cream Milk 1L", "Dairy", 56.0, 47.0))

Output

Product(product_id='P007', name='Full Cream Milk 1L', category='Dairy', price=56.0, cost=47.0)
Full Cream Milk 1L 16.1
True

How it works: @dataclass reads the annotated fields and writes __init__, __repr__ (the readable printout) and __eq__ (compare by value) for you. A plain class would need all of that typed by hand. @property makes margin_pct look like an attribute while it is computed from the other fields, so it can never get out of date.

Reading the result: The product prints as Product(product_id='P007', name='Full Cream Milk 1L', category='Dairy', price=56.0, cost=47.0), has a margin of 16.1%, and two objects with the same field values compare equal (True).

Example 4: Load the catalogue into dataclasses

Question: How do we turn CSV rows into a list of typed objects?

catalogue = [
    Product(r.product_id, r.product_name, r.category, float(r.unit_price), float(r.cost_price))
    for r in pd.read_csv("products.csv").itertuples(index=False)
]

print(len(catalogue))
print(catalogue[0])

best = max(catalogue, key=lambda p: p.margin_pct)
print(best.name, round(best.margin_pct, 1))

dairy = [p.name for p in catalogue if p.category == "Dairy"]
print(dairy)

Output

50
Product(product_id='P001', name='Banana Robusta', category='Fruits & Veg', price=48.0, cost=36.31)
Face Wash 100ml 34.5
['Full Cream Milk 1L', 'Curd 400g', 'Paneer 200g', 'Butter 100g', 'Cheese Slices 200g', 'Eggs Tray of 12', 'Greek Yogurt 100g']

How it works: Each CSV row becomes one Product, and from then on we use attribute access (p.price) instead of fragile dictionary keys (row["price"]); a typo such as p.pirce fails immediately with a clear error. Lists of objects work with max, sorted, comprehensions and anything else you know. For heavy numerical work, stay in pandas; use dataclasses for the business logic around the data.

Reading the result: The catalogue holds 50 products. The highest margin belongs to Face Wash 100ml at 34.5%, and 7 products are dairy: ['Full Cream Milk 1L', 'Curd 400g', 'Paneer 200g', 'Butter 100g', 'Cheese Slices 200g', 'Eggs Tray of 12', 'Greek Yogurt 100g'].

Example 5: Defaults, mutable fields and validation

Question: How do we give fields default values and reject bad data?

from dataclasses import field

@dataclass
class Basket:
    customer_id: str
    items: list[tuple[str, int]] = field(default_factory=list)
    discount_pct: int = 0

    def __post_init__(self) -> None:
        if not 0 <= self.discount_pct <= 50:
            raise ValueError(f"discount must be between 0 and 50, got {self.discount_pct}")

    def add(self, product_id: str, quantity: int = 1) -> None:
        self.items.append((product_id, quantity))

    @property
    def units(self) -> int:
        return sum(q for _, q in self.items)

b1 = Basket("C0001")
b2 = Basket("C0002")
b1.add("P001", 2)
b1.add("P010")
print(b1)
print(b2)
print(b1.units, b2.units)

try:
    Basket("C0003", discount_pct=90)
except ValueError as e:
    print("rejected:", e)

Output

Basket(customer_id='C0001', items=[('P001', 2), ('P010', 1)], discount_pct=0)
Basket(customer_id='C0002', items=[], discount_pct=0)
3 0
rejected: discount must be between 0 and 50, got 90

How it works: Simple defaults go straight on the field (discount_pct: int = 0). For mutable defaults such as lists you must use field(default_factory=list), so that each object gets its own new list; writing items: list = [] would be rejected because every basket would share one list. __post_init__ runs right after __init__ and is the place for validation, so an invalid object can never exist.

Reading the result: The first basket holds 3 units while the second is still empty (0), proving the lists are separate. A discount of 90% was rejected with "discount must be between 0 and 50, got 90".

Example 6: Immutable and ordered dataclasses

Question: How do we make records that cannot change, and that sort?

from dataclasses import FrozenInstanceError, replace, asdict

@dataclass(frozen=True, order=True)
class Store:
    city: str
    store_id: str

stores = [Store("Pune", "S06"), Store("Delhi", "S02"), Store("Mumbai", "S04")]
print(sorted(stores)[0])
print(stores[0] in set(stores), hash(stores[0]) == hash(Store("Pune", "S06")))

try:
    stores[0].city = "Goa"
except FrozenInstanceError as e:
    print("cannot modify:", type(e).__name__)

moved = replace(stores[0], city="Goa")
print(moved, asdict(moved))

Output

Store(city='Delhi', store_id='S02')
True True
cannot modify: FrozenInstanceError
Store(city='Goa', store_id='S06') {'city': 'Goa', 'store_id': 'S06'}

How it works: frozen=True makes instances read-only (and hashable, so they can go in sets and be dictionary keys). order=True adds comparison, sorting fields in the order they are declared (here by city first). To "change" a frozen object, create a modified copy with dataclasses.replace. asdict converts any dataclass to a dictionary, handy for JSON and DataFrames. Immutable records are safer, because no other part of the program can quietly alter them.

Reading the result: Sorting puts Delhi first (alphabetical by city). Changing a field raised FrozenInstanceError, and replace produced Store(city='Goa', store_id='S06') without touching the original (Pune).

Example 7: Enums instead of magic strings

Question: How do we stop typos like "Delivred" from creeping in?

from enum import Enum

class Status(Enum):
    DELIVERED = "Delivered"
    CANCELLED = "Cancelled"
    RETURNED = "Returned"

print(Status("Delivered"), Status("Delivered") is Status.DELIVERED)
print(Status.CANCELLED.value, [s.name for s in Status])

try:
    Status("Delivred")
except ValueError as e:
    print("invalid:", e)

orders = pd.read_csv("orders.csv")
statuses = orders["order_status"].map(Status)
counts = {s.name: int(n) for s, n in statuses.value_counts().items()}
print(counts)

Output

Status.DELIVERED True
Cancelled ['DELIVERED', 'CANCELLED', 'RETURNED']
invalid: 'Delivred' is not a valid Status
{'DELIVERED': 5391, 'CANCELLED': 370, 'RETURNED': 239}

How it works: An Enum defines a closed set of named constants. Status("Delivered") converts raw text to the member, and an unknown value raises ValueError straight away, so a typo in data or code cannot pass silently. Comparing status is Status.DELIVERED is safe from spelling mistakes, and your editor can autocomplete the names. Mapping a whole column through the Enum doubles as validation of the data: every row must be a known status.

Reading the result: All 6000 order statuses converted successfully, with counts {'DELIVERED': 5391, 'CANCELLED': 370, 'RETURNED': 239}. The misspelling "Delivred" was rejected immediately.

Example 8: Putting it together: a typed Order

Question: How do we combine dataclass, enum and validation in one record?

from datetime import date

@dataclass(frozen=True)
class Order:
    order_id: str
    customer_id: str
    order_date: date
    status: Status
    delivery_minutes: float | None = None

    def __post_init__(self) -> None:
        if self.status is Status.DELIVERED and self.delivery_minutes is None:
            raise ValueError(f"{self.order_id}: delivered order needs delivery_minutes")

    @property
    def is_on_time(self) -> bool | None:
        return None if self.delivery_minutes is None else self.delivery_minutes <= 30

typed = []
problems = 0
for r in orders.head(500).itertuples(index=False):
    minutes = None if pd.isna(r.delivery_minutes) else float(r.delivery_minutes)
    try:
        typed.append(Order(r.order_id, r.customer_id, date.fromisoformat(r.order_date), Status(r.order_status), minutes))
    except ValueError:
        problems += 1

print(len(typed), problems)
print(typed[0])
on_time = [o.is_on_time for o in typed if o.is_on_time is not None]
print(round(sum(on_time) / len(on_time), 3))

Output

500 0
Order(order_id='O02299', customer_id='C0078', order_date=datetime.date(2025, 1, 1), status=<Status.DELIVERED: 'Delivered'>, delivery_minutes=34.0)
0.837

How it works: The record validates itself: a delivered order must have a delivery time, and bad rows are counted instead of crashing the loader. date.fromisoformat converts the text date to a real date. The is_on_time property returns None when there is no delivery time, a three-valued answer that the type bool | None documents honestly. Loading data into validated objects at the edge of your program means the rest of the code can trust it.

Reading the result: 500 of the first 500 rows became valid Order objects with 0 rejected, and 83.7% of timed deliveries were on time.

Example 9: Docstrings that are also tests

Question: How do we document a function so that the examples cannot go out of date?

import doctest

def indian_grouping(amount: int) -> str:
    """Format a whole-rupee amount with Indian digit grouping.

    >>> indian_grouping(1234567)
    '12,34,567'
    >>> indian_grouping(999)
    '999'
    >>> indian_grouping(100000)
    '1,00,000'
    """
    s = str(amount)
    if len(s) <= 3:
        return s
    head, tail = s[:-3], s[-3:]
    parts = []
    while len(head) > 2:
        parts.insert(0, head[-2:])
        head = head[:-2]
    if head:
        parts.insert(0, head)
    return ",".join(parts + [tail])

test = doctest.DocTestFinder().find(indian_grouping, "indian_grouping", globs=globals())[0]
runner = doctest.DocTestRunner(verbose=False)
runner.run(test)
print(runner.summarize(verbose=False))

Output

TestResults(failed=0, attempted=3)

How it works: Lines starting with >>> inside a docstring are examples with their expected output underneath. doctest runs them and reports any mismatch, so the documentation is also a test: if someone changes the function and breaks the example, it fails. A good docstring has a one-line summary, a description of the arguments and result if it is not obvious, and one or two examples. The summarize result shows how many examples failed and how many were tried.

Reading the result: The runner tried 3 examples and 0 failed. Documentation that runs is documentation that stays true.

Example 10: Refactoring a messy function

Question: How do we improve code without changing what it does?

# BEFORE: one function, magic numbers, unclear names, mixed jobs
def f(d):
    t = 0
    for i in range(len(d)):
        if d[i]["s"] == "Delivered":
            if d[i]["m"] != "" and float(d[i]["m"]) <= 30:
                t = t + 1
    return round(t / len([x for x in d if x["s"] == "Delivered"]) * 100, 1)

rows = [{"s": r.order_status, "m": "" if pd.isna(r.delivery_minutes) else str(r.delivery_minutes)} for r in orders.itertuples()]
before = f(rows)

# AFTER: named constants, small pure functions, type hints, docstring
ON_TIME_LIMIT_MINUTES = 30

def is_delivered(order: Order) -> bool:
    return order.status is Status.DELIVERED

def is_on_time(order: Order) -> bool:
    return order.delivery_minutes is not None and order.delivery_minutes <= ON_TIME_LIMIT_MINUTES

def on_time_rate(orders_: list[Order]) -> float:
    """Percentage of delivered orders that arrived within the limit."""
    delivered = [o for o in orders_ if is_delivered(o)]
    on_time_count = sum(is_on_time(o) for o in delivered)
    return round(on_time_count / len(delivered) * 100, 1)

full = [
    Order(r.order_id, r.customer_id, date.fromisoformat(r.order_date), Status(r.order_status), None if pd.isna(r.delivery_minutes) else float(r.delivery_minutes))
    for r in orders.itertuples(index=False)
]
after = on_time_rate(full)
print(before, after, before == after)

Output

84.2 84.2 True

How it works: The "before" function works but hides its meaning: d, t, s, m, a magic number 30, an index loop, and the delivered count computed in a different place from the on-time count. The refactored version names the concept (ON_TIME_LIMIT_MINUTES), splits the logic into tiny functions you can test one at a time, uses typed objects and documents the result. Crucially we checked that the output is identical (before == after): refactoring means changing structure without changing behaviour, and a test or comparison is how you know.

Reading the result: Both versions report 84.2% on time, and the answers are identical. The second version can be extended (a different limit, a per-city rate) without touching the logic.

Common mistakes

1. Assuming type hints are enforced. They are not checked at runtime; use a type checker and explicit validation of external data.

2. Mutable default values (items: list = []). Use field(default_factory=list).

3. Magic strings and numbers scattered through the code. Use Enums and named constants.

4. Refactoring without a safety net. Compare outputs or run tests before and after.

5. Over-engineering. A dictionary or a DataFrame is fine for simple data. Introduce a class when behaviour or validation belongs with the data.

@dataclass
class Bad:
    items: list = []

Output

Traceback (most recent call last):
  File "example.py", line 1, in <module>
    @dataclass
     ^^^^^^^^^
  File "example.py", line 1354, in dataclass
    return wrap(cls)
  File "example.py", line 1344, in wrap
    return _process_class(cls, init, repr, eq, order, unsafe_hash,
                          frozen, match_args, kw_only, slots,
                          weakref_slot)
  File "example.py", line 1008, in _process_class
    cls_fields.append(_get_field(cls, name, type, kw_only))
                      ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "example.py", line 860, in _get_field
    raise ValueError(f'mutable default {type(f.default)} for field '
                     f'{f.name} is not allowed: use default_factory')
ValueError: mutable default <class 'list'> for field items is not allowed: use default_factory

Practice questions

Practice 1 (Easy): A Customer dataclass

Create a Customer dataclass with customer_id, name, city and is_prime (default False), load the first three customers from customers.csv and print them.

Show solution
@dataclass
class Customer:
    customer_id: str
    name: str
    city: str
    is_prime: bool = False

first3 = [Customer(r.customer_id, r.name, r.city, bool(r.is_prime)) for r in pd.read_csv("customers.csv").head(3).itertuples(index=False)]
for c in first3:
    print(c)

Output

Customer(customer_id='C0001', name='Isha Joshi', city='Delhi', is_prime=True)
Customer(customer_id='C0002', name='Priya Sharma', city='Hyderabad', is_prime=False)
Customer(customer_id='C0003', name='Suresh Singh', city='Chennai', is_prime=False)

The first customer is Isha Joshi from Delhi. Field order in a dataclass defines the constructor's argument order; fields with defaults must come last.

Practice 2 (Medium): Group products by category

Using the catalogue list, build a dictionary mapping each category to the average margin of its products, rounded to one decimal.

Show solution
from collections import defaultdict

margins: dict[str, list[float]] = defaultdict(list)
for p in catalogue:
    margins[p.category].append(p.margin_pct)

avg = {cat: round(sum(v) / len(v), 1) for cat, v in margins.items()}
print(avg)

Output

{'Fruits & Veg': 22.5, 'Dairy': 11.9, 'Staples': 10.0, 'Snacks': 28.2, 'Beverages': 24.4, 'Personal Care': 30.0, 'Household': 26.3}

The category with the best average margin is Personal Care (30.0%). defaultdict(list) creates the empty list the first time a key is seen, which removes an "if key not in dict" check.

Practice 3 (Hard): Validate every status in a file

Write a function load_statuses(rows: list[str]) -> tuple[list[Status], list[str]] that returns the valid statuses and a list of the invalid raw values, then test it with ["Delivered", "Cancelled", "Delivred", "", "Returned"].

Show solution
def load_statuses(rows: list[str]) -> tuple[list[Status], list[str]]:
    good, bad = [], []
    for raw in rows:
        try:
            good.append(Status(raw))
        except ValueError:
            bad.append(raw)
    return good, bad

good, bad = load_statuses(["Delivered", "Cancelled", "Delivred", "", "Returned"])
print([s.name for s in good], bad)

Output

['DELIVERED', 'CANCELLED', 'RETURNED'] ['Delivred', '']

The function accepted 3 values and rejected 2: ['Delivred', '']. Returning both lists lets the caller log the rejects and carry on, instead of one bad row stopping a whole load.

Key takeaways

  • Type hints document and tool-check your code, but are not enforced at runtime.
  • @dataclass generates __init__, __repr__ and __eq__; use field(default_factory=...) for mutable defaults and __post_init__ for validation.
  • frozen=True makes records immutable and hashable; replace creates modified copies.
  • Enums turn magic strings into a closed, typo-proof set.
  • Docstrings with >>> examples are documentation and tests in one.
  • Refactor in small steps and prove that behaviour is unchanged.

Frequently asked questions

Do I need type hints in data analysis scripts?

Not in a quick notebook. They pay off in shared code, libraries, scheduled jobs and anything that more than one person maintains.

Dataclass, NamedTuple or dictionary?

Use a dictionary for ad-hoc, short-lived data. A NamedTuple is a light, immutable record. A dataclass is the best default when you want methods, defaults, validation or mutability options. Use pydantic when you need heavy validation of external input.

What does from __future__ import annotations do?

It stores hints as strings so that you can refer to a class before it is defined and avoid some import-order problems. It is optional in modern Python.

How do I check types automatically?

Run mypy or pyright on your code (and in CI). They read the hints and report inconsistencies without running the program.

Are Enums slow?

No measurable cost for typical use. The safety they add is worth far more than a few microseconds.

How small should functions be?

Small enough to have one clear purpose and a name that says it: if you struggle to name a function, it probably does too much. A dozen lines is typical.

Newsletter

Keep learning Python

One email a week with Python and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.

Ready to practise? Try the Python interview questions