Skip to content

Your first class

Goal: define a class, create objects from it, and give them attributes and methods, using the to-do task as the example.

Why this matters

The to-do app stores a task as a dictionary with four keys. It works, but look at what it costs. Every function that touches a task must spell "title", "due", "priority" and "done" exactly right, and a slip is a KeyError at run time. Nothing stops you making a task with three keys, or five. And the functions that only make sense for a task, such as describe_due(task, today), float around the program with no connection to the thing they describe.

Python has a way of saying this is a kind of thing, here is what it holds, and here is what it can do. It is called a class, and you have been using classes all along without writing one. str, int, list, date: each is a class. This lesson shows you how to write your own.

A class is a new type

Here is the task, as a class:

task_class.py
from datetime import date

class Task:
    def __init__(self, title, due, priority):
        self.title = title
        self.due = due
        self.priority = priority
        self.done = False

milk = Task("Buy milk", date(2026, 9, 21), 2)
dentist = Task("Call the dentist", date(2026, 9, 18), 3)

print(milk.title)
print(milk.due)
print(milk.done)
print(dentist.title)

milk.done = True
print(milk.done)
Buy milk
2026-09-21
False
Call the dentist
True

Take the definition line by line.

  • class Task: starts the definition, the way def starts a function. The name is capitalised by convention, so that Task is visibly a class and task is visibly a variable.
  • Inside it is a function with the strange name __init__. Two underscores on each side mark a name that Python itself looks for. This one is run automatically whenever a new Task is created, and its job is to set the object up. Init is short for initialise.
  • Its first parameter is self. When you write Task("Buy milk", ...), Python creates a new, empty object, then calls __init__ with that object as self, followed by the values you gave. self is simply the object being set up, and every function inside a class has it as its first parameter.
  • self.title = title stores the value in the object, under the name title. This is an attribute, the same kind of thing as today.year in Module 8, except that this time you are the one creating it. Four lines create four attributes. done is not a parameter: every new task starts not done, so __init__ sets it itself.

Below the class, Task("Buy milk", date(2026, 9, 21), 2) makes a task. This looks exactly like date(2026, 9, 21), because it is exactly like it. date is a class, and calling a class makes a new value of that type. A value made from a class is called an object, and milk and dentist are two objects of the same class, each with its own attributes. milk.title reads one, and milk.done = True changes one, with no square brackets or quotes anywhere.

Telling Python how to print an object

Print a task as it stands and you get something unhelpful:

no_str.py
class Task:
    def __init__(self, title):
        self.title = title

milk = Task("Buy milk")
print(milk)
<__main__.Task object at 0x0000019FE68A64B0>

That is Python saying a Task object, at this address in memory, because it has no idea what a task should look like as text. You tell it with a second special function:

task_str.py
from datetime import date

class Task:
    def __init__(self, title, due, priority):
        self.title = title
        self.due = due
        self.priority = priority
        self.done = False

    def __str__(self):
        if self.done:
            mark = "[x]"
        else:
            mark = "[ ]"
        return f"{mark} {self.title} (due {self.due}, priority {self.priority})"

milk = Task("Buy milk", date(2026, 9, 21), 2)
print(milk)
milk.done = True
print(milk)
print(f"Next up: {milk}")
[ ] Buy milk (due 2026-09-21, priority 2)
[x] Buy milk (due 2026-09-21, priority 2)
Next up: [x] Buy milk (due 2026-09-21, priority 2)

__str__ must return a string, and Python calls it whenever it needs the object as text: in print(), in an f-string, in str(). Inside it, self is the object being printed, so self.title and self.done are that task's own values. Notice that nothing outside the class changed. print(milk) is the same call as before. The class just knows how to answer it now.

__init__ and __str__ are the only two of these double-underscore names you need for now. There are others, and you will meet them when you need them.

Methods: functions that belong to the class

You have called methods since Module 2: .upper() on a string, .append() on a list. A method is a function defined inside a class, and it works on the object it is called on. Here is the task with three:

task_methods.py
from datetime import date

class Task:
    def __init__(self, title, due, priority):
        self.title = title
        self.due = due
        self.priority = priority
        self.done = False

    def __str__(self):
        if self.done:
            mark = "[x]"
        else:
            mark = "[ ]"
        return f"{mark} {self.title} (due {self.due}, priority {self.priority})"

    def finish(self):
        """Mark the task as done."""
        self.done = True

    def is_overdue(self, today):
        """Return True if the task is not done and its due date has passed."""
        return not self.done and self.due < today

    def describe_due(self, today):
        """Return a short phrase saying when the task is due, or an empty string if it is done."""
        if self.done:
            return ""
        days = (self.due - today).days
        if days == 0:
            return "today"
        elif days == 1:
            return "tomorrow"
        elif days > 1:
            return f"in {days} days"
        else:
            return f"OVERDUE by {-days} days"

if __name__ == "__main__":
    today = date(2026, 9, 20)
    milk = Task("Buy milk", date(2026, 9, 21), 2)
    dentist = Task("Call the dentist", date(2026, 9, 18), 3)

    print(milk.describe_due(today))
    print(dentist.describe_due(today))
    print(dentist.is_overdue(today))

    dentist.finish()
    print(dentist)
    print(dentist.is_overdue(today))
tomorrow
OVERDUE by 2 days
True
[x] Call the dentist (due 2026-09-18, priority 3)
False

describe_due() is the function from the end of Module 9, moved inside the class. Two things changed. Its first parameter is self, and where it used to say task["due"] it now says self.due. The call changed to match: milk.describe_due(today), and Python passes milk as self, so you give one argument fewer than the def line has parameters. That is the rule for every method: the object goes before the dot, and everything else goes in the parentheses.

finish() takes nothing but self and changes the object: self.done = True. After dentist.finish(), the dentist task is done, and is_overdue() on it returns False, because a done task is never overdue.

The demo code at the bottom is under if __name__ == "__main__":, from Module 8, so that the next program can import the class without the demo running.

The task, side by side

With a dictionary With the class
{"title": "Buy milk", "due": ..., "priority": 2, "done": False} Task("Buy milk", ..., 2)
task["title"] task.title
task["done"] = True task.finish() or task.done = True
describe_due(task, today) task.describe_due(today)
print(task) shows the raw dictionary print(task) shows what you chose
A missing key is a KeyError at the moment of use A missing argument is a TypeError at the moment of creation
Any function can be handed any dictionary describe_due() can only be called on a Task

Nothing on the right is impossible on the left. The difference is that the class puts the shape of a task and the things a task can do in one place, with a name, and Python helps you keep them consistent. The next lesson looks at when that is worth it and when a dictionary is fine.

The table, with objects

Here is show_tasks() from Module 9 working on Task objects instead of dictionaries:

task_table.py
from datetime import date
from task_methods import Task

def due_date(task):
    """Return the part of a task to sort by."""
    return task.due

def show_tasks(tasks, today):
    """Print the tasks as a table, with a column saying when each one is due."""
    print(f"{'#':>2}  {'Title':<24}{'Due':<12}{'Pri':>3}  When")
    number = 1
    for task in tasks:
        print(f"{number:>2}  {task.title:<24}{str(task.due):<12}{task.priority:>3}  {task.describe_due(today)}")
        number += 1

today = date(2026, 9, 20)
tasks = [
    Task("Buy milk", date(2026, 9, 21), 2),
    Task("Finish Module 10", date(2026, 10, 3), 1),
    Task("Call the dentist", date(2026, 9, 18), 3),
]
tasks[2].finish()
tasks.sort(key=due_date)

show_tasks(tasks, today)
 #  Title                   Due         Pri  When
 1  Call the dentist        2026-09-18    3
 2  Buy milk                2026-09-21    2  tomorrow
 3  Finish Module 10        2026-10-03    1  in 13 days

The class is imported from task_methods.py like any module. The changes from the Module 9 version are all one kind: task['title'] became task.title, and the When column calls the method. Sorting works the same way as before, with a key function that returns task.due. Objects can go in lists, be passed to functions, and be sorted, exactly as dictionaries can, because they are values like any other.

Try it

Write a Dog class with a name and an age, a __str__ that says Rex, 3 years old, and a method birthday() that adds one to the age. Make two dogs, give one of them a birthday, and print both. Then, without running it, predict what print(Dog) and print(rex.birthday) show, with no parentheses. Run it and see.

Common mistakes

TypeError: Task.init() missing 2 required positional arguments: 'due' and 'priority'

You made a Task with too few values. Every parameter of __init__ except self must be supplied when you call the class. The message lists the ones you left out.

TypeError: Task.describe_due() takes 0 positional arguments but 1 was given

The method was defined without self. Python passed the object anyway, and there was no parameter to receive it. Every method's first parameter is self.

AttributeError: 'Task' object has no attribute 'titel'

A misspelled attribute, the class version of a misspelled key. Compare it with the names set in __init__.

AttributeError: 'Task' object has no attribute 'done', but I set it in init

You wrote done = False instead of self.done = False. Without self., it is a local variable that vanishes when __init__ ends. Attributes are always created through self.

NameError: name 'self' is not defined

self only exists inside methods. Outside the class, use the object's own name: milk.title, not self.title.

The output shows or

Parentheses are missing. Task is the class and Task(...) makes an object. milk.finish is the method and milk.finish() calls it.

NameError: name 'task' is not defined

The class is Task with a capital T. Python treats task and Task as different names.

Exercises

  1. Book. Write a Book class with a title, an author and a page count, a __str__ that gives Dune by Frank Herbert, 412 pages, and a method is_long() that returns True for more than 300 pages. Make a list of three books and print each, with a note after the long ones.
  2. Bank account. Write a BankAccount class with an owner and a balance that starts at 0, methods deposit() and withdraw(), and a __str__. withdraw() must refuse, with a message, to take out more than the balance.
  3. Dice. Write a Die class that takes a number of sides, with a roll() method that returns a random result and also counts how many times the die has been rolled. Make a six-sided and a twenty-sided die and roll each five times.
  4. Tasks to lines. Write task_to_line(task) and task_from_line(line) for Task objects, using the Module 9 file format. task_from_line() should return a new Task, finished if the line says yes. Make a done task, turn it into a line and back, and check that every attribute survived.
Solution 1
class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages

    def __str__(self):
        return f"{self.title} by {self.author}, {self.pages} pages"

    def is_long(self):
        """Return True if the book has more than 300 pages."""
        return self.pages > 300

books = [
    Book("The Hobbit", "J. R. R. Tolkien", 310),
    Book("Animal Farm", "George Orwell", 112),
    Book("Dune", "Frank Herbert", 412),
]

for book in books:
    print(book)
    if book.is_long():
        print("  That is a long one.")

is_long() returns a boolean, so it reads naturally in an if. A method that answers a yes-or-no question is usually named is_something, as is_overdue() was.

Solution 2
class BankAccount:
    def __init__(self, owner):
        self.owner = owner
        self.balance = 0

    def __str__(self):
        return f"{self.owner}'s account: {self.balance}"

    def deposit(self, amount):
        """Add money to the account."""
        self.balance += amount

    def withdraw(self, amount):
        """Take money out, unless there is not enough. Return True if it worked."""
        if amount > self.balance:
            print(f"Cannot withdraw {amount}: only {self.balance} available.")
            return False
        self.balance -= amount
        return True

account = BankAccount("Ada")
account.deposit(100)
account.withdraw(30)
account.withdraw(500)
print(account)

The balance is not a parameter, because a new account always starts at zero, just as a new task always starts not done. withdraw() returns True or False so that a caller can find out whether it worked.

Solution 3
import random

class Die:
    def __init__(self, sides):
        self.sides = sides
        self.rolls = 0

    def roll(self):
        """Return a random result and count the roll."""
        self.rolls += 1
        return random.randint(1, self.sides)

six = Die(6)
twenty = Die(20)

for i in range(5):
    print(f"d6: {six.roll()}   d20: {twenty.roll()}")

print(f"The six-sided die was rolled {six.rolls} times.")

Each die keeps its own count in self.rolls, so rolling the twenty-sided die does not change the six-sided one's number. That is the point of objects: each one has its own attributes.

Solution 4
from datetime import date
from task_methods import Task

def task_to_line(task):
    """Return a task as one line of text: title|YYYY-MM-DD|priority|yes or no."""
    if task.done:
        done = "yes"
    else:
        done = "no"
    return f"{task.title}|{task.due}|{task.priority}|{done}"

def task_from_line(line):
    """Return the Task described by one line of the file. A malformed line causes a ValueError."""
    title, due_text, priority_text, done_text = line.split("|")
    task = Task(title, date.fromisoformat(due_text), int(priority_text))
    if done_text == "yes":
        task.finish()
    return task

original = Task("Book flights, hotel and car", date(2026, 11, 1), 1)
original.finish()

line = task_to_line(original)
print(line)

back = task_from_line(line)
print(back)
print(back.title == original.title and back.due == original.due
      and back.priority == original.priority and back.done == original.done)

task_from_line() is a plain function, not a method, because there is no task yet when it starts: making one is its job. Save this next to task_methods.py, or the import fails. The next lesson puts these two functions to work in the app.

Summary

  • A class defines a new type. Calling the class, Task(...), makes an object of that type, exactly as date(...) does.
  • __init__(self, ...) runs when an object is made and creates its attributes with self.name = value.
  • self is the object being worked on. Every method takes it as its first parameter, and Python fills it in from the object before the dot.
  • __str__(self) returns the text print() shows for the object.
  • A method is a function inside a class. task.describe_due(today) calls it on that task.
  • Objects live in lists, get passed to functions and are sorted with key=, like any other value.

Next: Objects in the to-do app