Python classes practice — three programs: an account that can’t be tampered with, the faculty library and a copy shop with VAT for all (and the −4000 € balance nobody stopped)
The first BankAccount class I wrote for a lab worked beautifully in my tests: deposit, withdraw, print. I gave it to a classmate to try and within a minute the account was at −4000 €: all it took was withdrawing more than there was. In the second minute he had written account.balance = 'loads' and the program blew up on the next deposit. My class stored data, but it didn’t protect it. Today we fix that.
In the Python classes theory you saw __init__, self, class attributes, encapsulation with @property and magic methods. Now we use them in three complete, medium-level programs: a bank account that evolves from “anyone can touch it” to “nobody can break it”, the faculty library with books that are lent and returned, and the faculty copy shop, where a single VAT change affects every product at once. The data has traps on purpose (impossible withdrawals, years in the future, empty titles), because a well-made class shows its worth exactly when someone tries to misuse it.
Table of Contents
Before you start
Make sure you are clear on:
- That
self.x = xin__init__is what stores the value in the object. - How a property is written:
@propertyto read and@name.setterto change with validation. - The difference between a class attribute (one for all) and an instance attribute (one per object).
- That
__str__is whatprintshows and__repr__, what you see inside a list.
If any of these feels shaky, review the theory before going on.
In all three programs the errors are triggered on purpose at the end, inside a loop with try, so you see each one’s real message. exec(order) runs each line written as text: it’s just a convenient way to show several exceptions in a row, not something you’ll need in your programs.
Python classes practice — Program 1: Bank account (step by step evolution)
An account with a holder and a balance that allows deposits and withdrawals. What makes it tricky is that the balance can never end up in an absurd state: not negative, not with text inside, not with fake entries in the history. We’ll see it in two versions: the naive one (mine) and the encapsulated one.
The idea
In the first version everything is public and nothing is checked. In the second, the balance lives in self.__balance and can only be touched through three doors: the setter (which validates type and sign), deposit and withdraw (which validate the amount and that there are funds). The holder is read-only and the history is handed out as a copy.
Before programming, write down every way of leaving the account in an impossible state. Each one has to end in a raise.
The code
First, the naive version and what happened to it:
class BankAccount:
def __init__(self, holder, initial_balance=0):
self.holder = holder
self.balance = initial_balance # public attribute: anyone can change it
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
self.balance -= amount # does not check whether there is money
def __str__(self):
return f'Account of {self.holder}: {self.balance:.2f} €'
account = BankAccount('Laura', 1000)
account.withdraw(5000) # goes negative without complaining
print(account)
account.balance = 'loads' # and now it is not even a number
print(account.balance)
account.deposit(50)
Output:
Account of Laura: -4000.00 €
mucho
Traceback (most recent call last):
File "account_v1.py", line 21, in <module>
account.deposit(50)
~~~~~~~~~~~~~~~^^^^
File "account_v1.py", line 7, in deposit
self.balance += amount
TypeError: can only concatenate str (not "int") to str
1000 − 5000 = −4000: the account goes negative without complaining. Then account.balance = 'loads' puts text where a number should be, and the error doesn’t appear there but on the next deposit, with a TypeError that has nothing to do with the real problem. That’s the worst that can happen: the failure shows up far from where it was caused.
And now the encapsulated version:
class BankAccount:
_total_accounts = 0 # class attribute: how many accounts there are
def __init__(self, holder, initial_balance=0):
if not isinstance(holder, str) or not holder.strip():
raise ValueError('the holder must be a non-empty text')
self.__holder = holder.strip()
self.__transactions = []
self.balance = initial_balance # goes through the setter: validates on creation too
BankAccount._total_accounts += 1
@property
def holder(self): # read-only: no setter
return self.__holder
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, value):
if not isinstance(value, (int, float)):
raise TypeError('the balance must be a number')
if value < 0:
raise ValueError('the balance cannot be negative')
self.__balance = value
@property
def transactions(self):
return list(self.__transactions) # copy: the real list never leaves
def deposit(self, amount):
if amount <= 0:
raise ValueError('the deposit amount must be positive')
self.__balance += amount
self.__transactions.append(f'+{amount:.2f}')
def withdraw(self, amount):
if amount <= 0:
raise ValueError('the withdrawal amount must be positive')
if amount > self.__balance:
raise ValueError(f'insufficient funds: you have {self.__balance:.2f} €')
self.__balance -= amount
self.__transactions.append(f'-{amount:.2f}')
@classmethod
def total_accounts(cls):
return cls._total_accounts
def __str__(self):
return f'Account of {self.__holder}: {self.__balance:.2f} €'
def __repr__(self):
return f'BankAccount({self.__holder!r}, {self.__balance})'
c1 = BankAccount('Laura', 1000)
c2 = BankAccount('Pablo', 500)
c1.deposit(250)
c1.withdraw(100)
print(c1)
print(c1.transactions)
history = c1.transactions
history.append('+1000000.00') # trying to sneak in a transaction
print(c1.transactions) # it did not get in: it was a copy
print(repr(c2), '| accounts:', BankAccount.total_accounts())
for order in ['c1.withdraw(5000)', 'c1.balance = -100', "c1.balance = 'loads'", "c1.holder = 'Pablo'"]:
try:
exec(order)
except (ValueError, TypeError, AttributeError) as err:
print(f'{order:<20} {type(err).__name__}: {err}')
Zoom in on the heart of the class, the balance property:
@property
def balance(self):
return self.__balance
@balance.setter
def balance(self, value):
if not isinstance(value, (int, float)):
raise TypeError('the balance must be a number')
if value < 0:
raise ValueError('the balance cannot be negative')
self.__balance = value
The getter just returns self.__balance. The setter checks two things, each with its own exception: the type (TypeError if it isn’t a number) and the sign (ValueError if it is negative). So yes: setting the balance to a negative value through the setter raises ValueError. In __init__, self.balance = initial_balance goes through the setter, so you can’t even create an account starting at −50 €.
Output
Account of Laura: 1150.00 €
['+250.00', '-100.00']
['+250.00', '-100.00']
BankAccount('Pablo', 500) | accounts: 2
c1.withdraw(5000) ValueError: insufficient funds: you have 1150.00 €
c1.balance = -100 ValueError: the balance cannot be negative
c1.balance = 'loads' TypeError: the balance must be a number
c1.holder = 'Pablo' AttributeError: property 'holder' of 'BankAccount' object has no setter
Check: 1000 + 250 − 100 = 1150. The history stores +250.00 and -100.00. The attempt to sneak in a one-million entry doesn’t get in: history is a copy. And the four attacks at the end each end in their exception: no funds, the balance can’t be negative, nor a text, and the holder has no setter.
The drawing shows why the million didn’t get in: c1.transactions returns list(self.__transactions), a new list with the same data. The append lands in that copy and the inner one never notices.
Questions you might ask
Why do deposit and withdraw use self.__balance and not the setter? Because they already validate what matters to them (positive amount, enough funds) and the result can never be negative. Going through the setter would repeat the check. The setter is the door for whoever assigns the balance directly.
Why doesn’t the holder have a setter? Because in a real account the holder doesn’t change with an assignment. Without a setter, c1.holder = 'Pablo' gives AttributeError and Python’s message explains it: property 'holder' … has no setter.
Why TypeError for text and ValueError for a negative? As we saw in exceptions: text is the wrong type; −100 is the right type with a value that isn’t allowed. Using the precise exception lets whoever uses the class handle them separately.
What if someone writes c1._BankAccount__balance = -999? It works: name mangling isn’t a lock. But they have to do it on purpose, writing a weird name that shouts “don’t touch”. Encapsulation in Python protects against mistakes, not against someone who wants to get round it.
Writing return self.__transactions in the property. It works in all your tests… until someone does c1.transactions.append(...) and changes the real history without going through deposit.
Try it yourself
- Add a
transfer(self, other, amount)method that withdraws from this account and deposits intoother, raisingTypeErrorifotherisn’t aBankAccount. - Add an overdraft limit as a class attribute (
OVERDRAFT = 100) and letwithdrawgo down to −100 €, but no further.
Python classes practice — Program 2: Book library
The faculty library catalogue: a Book class with title, author, year and ISBN, which is lent and returned, and a Library class that stores books and lets you search them. What makes it tricky is that two classes work together and the data must always be validated: an empty title or a book published in 2030 can’t get in.
The idea
Book validates in its setters (non-empty title and author, a year between 1450, roughly the printing press, and the current year), has a read-only ISBN and controls its own state with lend and give_back, which raise ValueError if the operation makes no sense. Library doesn’t copy books: it stores references to the Book objects in a private list, and checks with isinstance that only books get in.
Before programming, decide who is responsible for what. Who knows whether a book is on loan? The book itself. Who knows which books there are? The library.
The code
CURRENT_YEAR = 2026
class Book:
_total_books = 0
def __init__(self, title, author, year, isbn):
self.title = title # setters: they validate from the start
self.author = author
self.year = year
self.__isbn = isbn # no setter: the ISBN does not change
self.__available = True
Book._total_books += 1
@property
def title(self):
return self.__title
@title.setter
def title(self, value):
if not isinstance(value, str) or not value.strip():
raise ValueError('the title must be a non-empty text')
self.__title = value.strip()
@property
def author(self):
return self.__author
@author.setter
def author(self, value):
if not isinstance(value, str) or not value.strip():
raise ValueError('the author must be a non-empty text')
self.__author = value.strip()
@property
def year(self):
return self.__year
@year.setter
def year(self, value):
if not isinstance(value, int) or not 1450 <= value <= CURRENT_YEAR:
raise ValueError(f'invalid year: {value}')
self.__year = value
@property
def isbn(self):
return self.__isbn
@property
def available(self):
return self.__available
def lend(self):
if not self.__available:
raise ValueError(f'«{self.__title}» is already on loan')
self.__available = False
def give_back(self):
if self.__available:
raise ValueError(f'«{self.__title}» was not on loan')
self.__available = True
@classmethod
def total_books(cls):
return cls._total_books
def __str__(self):
status = 'available' if self.__available else 'on loan'
return f'«{self.__title}», {self.__author} ({self.__year}): {status}'
def __repr__(self):
return f'Book({self.__title!r}, {self.__author!r}, {self.__year}, {self.__isbn!r})'
class Library:
def __init__(self, name):
self.__name = name
self.__books = [] # stores REFERENCES to Book objects
def add(self, book):
if not isinstance(book, Book):
raise TypeError('only Book objects can be added')
self.__books.append(book)
def search(self, text):
return [l for l in self.__books if text.lower() in l.title.lower()]
def available_books(self):
return [l for l in self.__books if l.available]
def __str__(self):
return f'Library {self.__name}: {len(self.__books)} books'
lib = Library('of the EII')
l1 = Book('Python for Everybody', 'Severance', 2016, '978-1-5303-3762-6')
l2 = Book('Data Structures in C', 'Joyanes', 2005, '978-84-481-4227-6')
l3 = Book('Calculus', 'Stewart', 2018, '978-607-526-549-8')
for l in [l1, l2, l3]:
lib.add(l)
print(lib)
l1.lend()
print(l1)
for action in [l1.lend, l3.give_back]:
try:
action()
except ValueError as err:
print('Error:', err)
print('Search "c":', lib.search('c'))
print('Available:', [l.title for l in lib.available_books()])
for bad in [("''", 'Nobody', 2020), ('Future', 'Someone', 2030)]:
try:
Book(eval(bad[0]) if bad[0] == "''" else bad[0], bad[1], bad[2], '000')
except ValueError as err:
print('Not created:', err)
print('Books created:', Book.total_books())
The two methods that change the book’s state:
def lend(self):
if not self.__available:
raise ValueError(f'«{self.__title}» is already on loan')
self.__available = False
def give_back(self):
if self.__available:
raise ValueError(f'«{self.__title}» was not on loan')
self.__available = True
Each one first checks whether the operation is possible and, if not, raises ValueError with the title in the message. That way, the error loop at the end can handle l1.lend and l3.give_back the same way.
Output
Library of the EII: 3 books
«Python for Everybody», Severance (2016): on loan
Error: «Python for Everybody» is already on loan
Error: «Calculus» was not on loan
Search "c": [Book('Data Structures in C', 'Joyanes', 2005, '978-84-481-4227-6'), Book('Calculus', 'Stewart', 2018, '978-607-526-549-8')]
Available: ['Data Structures in C', 'Calculus']
Not created: the title must be a non-empty text
Not created: invalid year: 2030
Books created: 3
What each line comes from:
| Output line | What produces it |
|---|---|
Library of the EII: 3 books | Library.__str__ with len(self.__books) |
«Python for Everybody»…: on loan | l1.lend() and Book.__str__ |
is already on loan | The second l1.lend() in the loop |
was not on loan | l3.give_back(): Calculus was never lent |
Search "c": [Book(...), Book(...)] | A list: each book’s __repr__ shows |
Available: [...] | Titles only: __repr__ no longer needed |
Not created: … (twice) | The title and year setters on creation |
Look at the search: “c” appears in “Data Structures in C” and in “Calculus” (thanks to lower() on both sides), but not in “Python for Everybody”. And the counter says 3: the two bad books raised the exception before reaching Book._total_books += 1, so they don’t count.
The drawing explains why “Python for Everybody” no longer shows as available: l1 and the first slot of the library’s list point to the same object. What you do through one path is seen through the other.
Questions you might ask
Why CURRENT_YEAR = 2026 and not the system date? So the program always gives the same output. In a real program you’d use datetime.date.today().year.
Why does search return books and not titles? Because whoever searches usually wants to do something with the book (lend it). If it returned titles, they’d have to look for the object again.
What if I add the same book twice? The list would hold two references to the same object. A good “Try it yourself” is preventing it by checking if book in self.__books.
Why does the ISBN have a getter but no setter? Because it identifies the edition: if it changes, it’s another book. The property without a setter makes it visible but immutable.
Checking in Library whether a book is on loan by peeking at one of its attributes (book._Book__available). Ask the book through its public door (book.available): if one day it changes how it stores the data, the library won’t notice.
Try it yourself
- Add a
lend(title)method toLibrarythat finds the book by exact title and lends it, or raisesValueErrorif it doesn’t exist. - Make
addraiseValueErrorif there is already a book with the same ISBN.
Python classes practice — Program 3: Product store
The faculty copy shop sells lecture notes, notebooks and USB sticks. Each product has a name, a price before tax and stock, and its final price includes VAT. What makes it tricky is that the tax is the same for everyone and can change (in the Canary Islands there is no VAT but IGIC, at 7 %): changing it product by product would be an accident waiting to happen.
The idea
The tax is a class attribute, _vat, and it is changed with a @classmethod that validates it. The price with VAT isn’t stored: it is a computed property obtained each time from the price and the current _vat. So when the tax changes, every final price changes on its own. apply_discount reuses the price setter to round and validate.
Before programming, think about which data belongs to each product (name, price, stock) and which belongs to all (the tax).
The code
class Product:
_vat = 0.21 # class attribute: the same VAT for all
def __init__(self, name, price, stock=0):
self.name = name
self.price = price
self.__stock = 0
self.add_stock(stock) # reuses the method's validation
@property
def name(self):
return self.__name
@name.setter
def name(self, value):
if not isinstance(value, str) or not value.strip():
raise ValueError('the name must be a non-empty text')
self.__name = value.strip()
@property
def price(self):
return self.__price
@price.setter
def price(self, value):
if not isinstance(value, (int, float)) or value < 0:
raise ValueError('the price must be a non-negative number')
self.__price = round(value, 2)
@property
def price_with_vat(self): # computed property: not stored, calculated
return round(self.__price * (1 + Product._vat), 2)
@property
def stock(self):
return self.__stock
def add_stock(self, amount):
if not isinstance(amount, int) or amount < 0:
raise ValueError('the quantity must be a non-negative integer')
self.__stock += amount
def sell(self, amount=1):
if not isinstance(amount, int) or amount <= 0:
raise ValueError('the quantity must be a positive integer')
if amount > self.__stock:
raise ValueError(f'insufficient stock: {self.__stock}')
self.__stock -= amount
return round(self.price_with_vat * amount, 2)
def apply_discount(self, percentage):
if not 0 < percentage < 100:
raise ValueError('the discount must be between 0 and 100')
self.price = self.__price * (1 - percentage / 100) # goes through the setter
@classmethod
def change_vat(cls, new):
if not 0 <= new <= 1:
raise ValueError('VAT must be between 0 and 1')
cls._vat = new
def __str__(self):
return f'{self.__name}: {self.__price:.2f} € (with VAT {self.price_with_vat:.2f} €), stock {self.__stock}'
print('=== FACULTY COPY SHOP ===')
notes = Product('FP2 lecture notes', 12.40, 30)
notebook = Product('A4 notebook', 3.50, 50)
usb = Product('USB stick 64 GB', 9.90, 4)
for p in [notes, notebook, usb]:
print(p)
print('Selling 3 notebooks:', notebook.sell(3), '€')
usb.apply_discount(20)
print(usb)
Product.change_vat(0.07) # the Canary Islands IGIC tax, for all at once
print([p.price_with_vat for p in [notes, notebook, usb]])
for order in ['usb.sell(10)', 'notes.price = -5', 'notebook.sell(1.5)', 'Product.change_vat(7)']:
try:
exec(order)
except ValueError as err:
print(f'{order:<24} {err}')
The pair that makes shared VAT work:
@property
def price_with_vat(self): # computed property: not stored, calculated
return round(self.__price * (1 + Product._vat), 2)
And the class method that changes it:
@classmethod
def change_vat(cls, new):
if not 0 <= new <= 1:
raise ValueError('VAT must be between 0 and 1')
cls._vat = new
price_with_vat has neither a setter nor an attribute of its own: it reads Product._vat every time someone asks. And change_vat uses cls._vat = new, which changes the attribute of the class. If you wrote self._vat = new in a normal method, you’d create a new attribute in a single product (the trap from the theory).
Output
=== FACULTY COPY SHOP === FP2 lecture notes: 12.40 € (with VAT 15.00 €), stock 30 A4 notebook: 3.50 € (with VAT 4.23 €), stock 50 USB stick 64 GB: 9.90 € (with VAT 11.98 €), stock 4 Selling 3 notebooks: 12.69 € USB stick 64 GB: 7.92 € (with VAT 9.58 €), stock 4 [13.27, 3.75, 8.47] usb.sell(10) insufficient stock: 4 notes.price = -5 the price must be a non-negative number notebook.sell(1.5) the quantity must be a positive integer Product.change_vat(7) VAT must be between 0 and 1
Let’s check the sums:
| Product | Price | With 21 % VAT | With 7 % IGIC |
|---|---|---|---|
| FP2 lecture notes | 12.40 | 12.40 × 1.21 = 15.004 → 15.00 | 12.40 × 1.07 = 13.268 → 13.27 |
| A4 notebook | 3.50 | 3.50 × 1.21 = 4.235 → 4.23 | 3.50 × 1.07 = 3.745 → 3.75 |
| USB stick (−20 %) | 9.90 × 0.8 = 7.92 | 7.92 × 1.21 = 9.5832 → 9.58 | 7.92 × 1.07 = 8.4744 → 8.47 |
Selling 3 notebooks charges 3 × 4.23 = 12.69 € with VAT. Notice the two “odd” roundings: 4.235 gives 4.23 and 3.745 gives 3.75. It isn’t a bug in the class: decimals like 4.235 can’t be stored exactly in binary (it is really 4.23499999…), and round works with the stored number.
This is the lookup rule from the theory: _vat lives in the class and each product finds it when looking it up; that’s why a single Product.change_vat(0.07) changes all three final prices.
Questions you might ask
Why does sell return the amount with VAT and not price * amount? Because what the copy shop charges is the final price. The old article returned the price before tax, which is not what you pay.
Why does apply_discount write self.price = … instead of self.__price = …? To go through the setter, which rounds to two decimals and checks it isn’t negative. Reusing the validation is safer than repeating it.
Why does notebook.sell(1.5) fail if 1.5 is positive? Because you don’t sell one and a half notebooks: isinstance(amount, int) rejects it.
Why does _vat have an underscore? It’s “private by convention”: you can read it, but to change it there is change_vat, which validates. If anyone did Product._vat = 7, every price would be multiplied by 8.
Storing the price with VAT as an attribute in __init__ (self.__price_vat = price * 1.21). It works until the tax changes: the products already created keep the old price.
Try it yourself
- Add a class attribute
_products = [](here sharing does make sense) where each product registers itself on creation, and a@classmethodthat returns the total value of the stock. - Add
__repr__and check the difference when printing[notes, notebook].
Common mistakes in this practice
- Public attributes for data that needs protecting: anyone can leave them in an impossible state and the error shows up far away.
- Returning the inner list from a property: whoever receives it can modify the object without going through its methods.
- Validating only in setters and forgetting
__init__: assign in__init__withself.x = x(notself.__x) to go through the validation. - Changing a class attribute through
self.: it creates a new attribute in one object instead of changing it for all; usecls.or the class name. - Storing computed values that depend on others: better a computed property, which is never out of date.
- Generic exceptions:
TypeErrorfor the type,ValueErrorfor the value, and a message saying which.
Watch it step by step in Python Tutor
In Python Tutor (choose Python 3) you can clearly see program 1’s copy trick, and also how the mangled attributes appear:
class Account:
def __init__(self, holder, balance):
self.__holder = holder
self.__transactions = []
self.__balance = balance
@property
def transactions(self):
return list(self.__transactions)
def deposit(self, c):
self.__balance += c
self.__transactions.append(c)
c = Account('Laura', 100)
c.deposit(50)
copied = c.transactions
copied.append(999)
print(c.transactions, copied)
Output:
[50] [50, 999]
What to watch:
- When
cis created, the object shows the attributes_Account__holder,_Account__transactionsand_Account__balance: name mangling live. - After
c.deposit(50), the object’s transactions list contains50. - At
copied = c.transactions, look at the arrows:copiedpoints to a new list, different from the object’s. - After
copied.append(999), onlycopied‘s list changes. The output confirms it:[50]versus[50, 999].
Summary and next step
- A public attribute without validation allows impossible states; a property with a setter stops them the moment they are attempted.
- Assign in
__init__through the properties so an object can’t be born broken. - Return copies (
list(...)) of anything mutable: the inner list is only touched by the methods. - Classes work together by storing references: the library and
l1see the same book. - What is the same for everyone (the tax) goes in a class attribute, changed with a
@classmethod. - What can be calculated (the price with VAT) is better as a computed property than as stored data.
Checklist: have you practised it?
- Can you explain why version 1 of the account gives the
TypeErrorindepositand not in the assignment? - Do you understand why the million didn’t get into the history?
- Could you say why the book counter is 3 even though 5 were attempted?
- Can you explain what would happen if
change_vatusedselfinstead ofcls? - Have you run the three programs and checked the outputs?
- Have you tried the “Try it yourself” tasks without looking at the theory?
If you answered yes to almost all of them, you are ready for the Python classes exercises, where I no longer give you the code. See you there!
Next step: exercisesPython classes exercises — a student and their grades, a vehicle that loses value and a stack with its own exceptions (and Laura’s report with Pablo’s fail on it)Did this article help you? Here is how you can support SergioLearns →




The hardware that holds up the operating system — theory with my IC2 assignment on the printer, an interrupt that stops everything and a cache that hits (and the infinite loop that didn’t freeze my laptop)