Python OOP Project: Build a Bank Account Manager (Capstone)

This Python OOP project answers the question every beginner quietly asks after learning classes: when would I actually create one? We will build a small bank account manager that uses inheritance, magic methods, properties, custom exceptions, and JSON persistence, the exact tools you met one at a time across the OOP chapter, snapped together into a program you can run and keep.

“An expert is a person who has made all the mistakes that can be made in a very narrow field.”

Niels Bohr

Last Updated: July 2026 | Tested on: Python 3.14.6 | Difficulty: Intermediate | Reading Time: 19 minutes

You have learned classes, inheritance, magic methods, encapsulation, and the @property decorator as separate lessons. This is where they stop being isolated tricks and become one design. A bank is a great fit because the rules are strict and familiar: money cannot go negative by accident, different account types behave differently, and the whole thing has to survive a restart. We will build it in six small increments, running each one so you see real output before moving on.

Think of it like building with Lego where each brick has a shape you already know. A plain Account is the base plate. SavingsAccount and CheckingAccount click on top and change one behavior each. The Bank is the box that holds them all. By the end you have a repository worth keeping, which is exactly the small, real project you will put under Git next in the series.

What We Are Building

Good projects start with a definition of done. It is like a recipe card: you list the dish and the steps before you touch a pan, so you know when the meal is ready. Here is our acceptance criteria, the checklist this Python OOP project has to satisfy.

  • Open accounts that track an owner, a number, and a balance
  • Deposit and withdraw money, with the balance protected from negative or direct edits
  • Support two account types: savings (earns interest) and checking (allows a small overdraft)
  • Print cleanly, compare by account number, and report how many transactions it holds
  • Transfer money between accounts as one safe operation
  • Save to a file and load back, rebuilding the correct account type each time

The diagram below is our blueprint. It shows the class hierarchy (which classes inherit from which), the composition (the Bank holding many accounts), and the small family of custom exceptions. Keep it in mind as we build each piece.

inheritsinheritscontains manyraiseshas subclassesSavingsAccountadds add_interestCheckingAccountoverrides withdrawfor overdraftAccount (base)@property balancedeposit / withdraw__str__ / __eq__ / __len__Bank (composition)holds many accountsopen_account / transfer /saveBankError (base exception)Subclasses raised on badinput:InvalidAmountErrorInsufficientFundsErrorAccountNotFoundErrorBank Account Manager Design: Inheritance, Composition, and Custom Errors

Tip: click the diagram to open it full screen, then use the zoom and pan controls for a closer look.

Increment 1: The Account Class

The heart of the system is a single account. It needs a balance nobody can quietly corrupt, so we store the real number in self._balance and expose it through a read-only @property. Deposits and withdrawals are the only way in and out, and each one checks the amount first. When something is wrong, we raise a named error rather than returning None or printing. Say a customer named Aditi opens an account: watch how the class refuses bad money instead of trusting it.

📄 run1_account.py: a protected balance with named errors

class BankError(Exception):
    """Base error for anything the bank rejects."""


class InvalidAmountError(BankError):
    pass


class InsufficientFundsError(BankError):
    pass


class Account:
    def __init__(self, owner, number, balance=0.0):
        self.owner = owner
        self.number = number
        self._balance = 0.0
        self._transactions = []
        if balance:
            self.deposit(balance)

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise InvalidAmountError(f"deposit must be positive, got {amount}")
        self._balance = round(self._balance + amount, 2)
        self._transactions.append(("deposit", round(amount, 2)))
        return self._balance

    def withdraw(self, amount):
        if amount <= 0:
            raise InvalidAmountError(f"withdrawal must be positive, got {amount}")
        if amount > self._balance:
            raise InsufficientFundsError(
                f"cannot withdraw {amount:.2f}, balance is {self._balance:.2f}")
        self._balance = round(self._balance - amount, 2)
        self._transactions.append(("withdraw", round(amount, 2)))
        return self._balance


acc = Account("Aditi", "SB-001", 1000)
acc.deposit(500)
acc.withdraw(300)
print(f"Owner: {acc.owner}, Account: {acc.number}")
print(f"Balance after deposit and withdraw: Rs.{acc.balance:,.2f}")

try:
    acc.balance = 999999
except AttributeError as e:
    print(f"Blocked direct write: {e}")

try:
    acc.withdraw(50000)
except InsufficientFundsError as e:
    print(f"Rejected: {e}")

try:
    acc.deposit(-10)
except InvalidAmountError as e:
    print(f"Rejected: {e}")

▶ Output

Owner: Aditi, Account: SB-001
Balance after deposit and withdraw: Rs.1,200.00
Blocked direct write: property 'balance' of 'Account' object has no setter
Rejected: cannot withdraw 50000.00, balance is 1200.00
Rejected: deposit must be positive, got -10

What happened here: the balance behaves like a locked drawer. You can read acc.balance, but assigning to it raises AttributeError because there is no setter, so no stray line of code can set the balance to a million. Every change flows through deposit or withdraw, and each records what it did in self._transactions. The two custom exceptions give the failures real names: InsufficientFundsError for an overdraw, InvalidAmountError for junk input. If the @property and custom exception ideas feel fuzzy, the property decorator tutorial and the custom exceptions tutorial are where they come from.

Increment 2: Savings and Checking (Inheritance)

Real banks offer more than one kind of account, but they share most of the plumbing. That is exactly what inheritance is for: put the shared behavior in the base class, then let each child add or change just one thing. A SavingsAccount adds interest. A CheckingAccount keeps everything the same except it overrides withdraw to allow a small overdraft. Anvi uses savings, Aviraj uses checking. One practical note: this snippet shows only the new code. Paste it into the same file as the Account class from Increment 1 (below it), so Account and the two exceptions are already defined when Python reads these lines.

📄 run2_inherit.py: two account types from one base

class SavingsAccount(Account):
    def __init__(self, owner, number, balance=0.0, rate=0.04):
        super().__init__(owner, number, balance)
        self.rate = rate

    def add_interest(self):
        interest = round(self._balance * self.rate, 2)
        self.deposit(interest)
        return interest


class CheckingAccount(Account):
    def __init__(self, owner, number, balance=0.0, overdraft=500.0):
        super().__init__(owner, number, balance)
        self.overdraft = overdraft

    def withdraw(self, amount):
        if amount <= 0:
            raise InvalidAmountError(f"withdrawal must be positive, got {amount}")
        limit = self._balance + self.overdraft
        if amount > limit:
            raise InsufficientFundsError(
                f"cannot withdraw {amount:.2f}, overdraft limit is {limit:.2f}")
        self._balance = round(self._balance - amount, 2)
        self._transactions.append(("withdraw", round(amount, 2)))
        return self._balance


savings = SavingsAccount("Anvi", "SB-100", 2000, rate=0.05)
earned = savings.add_interest()
print(f"Savings: added Rs.{earned:.2f} interest, balance Rs.{savings.balance:,.2f}")

checking = CheckingAccount("Aviraj", "CA-200", 300, overdraft=500)
checking.withdraw(700)  # more than the balance, but inside the overdraft
print(f"Checking: balance after overdraft Rs.{checking.balance:,.2f}")

try:
    savings.withdraw(999999)
except InsufficientFundsError as e:
    print(f"Savings rejects: {e}")

print(f"Is SavingsAccount an Account? {isinstance(savings, Account)}")

▶ Output

Savings: added Rs.100.00 interest, balance Rs.2,100.00
Checking: balance after overdraft Rs.-400.00
Savings rejects: cannot withdraw 999999.00, balance is 2100.00
Is SavingsAccount an Account? True

What happened here: both children call super().__init__(...) so the base sets up the owner, number, balance, and transaction list for free. SavingsAccount only adds add_interest, which reuses the existing deposit so interest is logged like any other credit. CheckingAccount overrides just withdraw to permit going below zero down to the overdraft floor, which is why Aviraj can pull 700 from a 300 balance and land at -400. The savings account still refuses to overdraw. And isinstance(savings, Account) is True, so anything written for an Account works on both types. The inheritance tutorial covers super() and method overriding in depth.

Increment 3: Magic Methods

Right now printing an account shows an ugly memory address. Magic methods let your objects behave like built-in types. Think of them as adapters that let Python’s own tools plug into your class: print() reaches for __str__, the shell and lists reach for __repr__, == reaches for __eq__, and len() reaches for __len__. We wire all four into Account. The four indented methods below go inside the Account class from Increment 1 (they are not a standalone file), and the demo lines after them sit at the bottom of that same file. The Full Program section further down shows everything assembled if you want to check your placement.

📄 run3_magic.py: making accounts print, compare, and measure

    def __str__(self):
        return f"{self.number} ({self.owner}): Rs.{self._balance:,.2f}"

    def __repr__(self):
        return f"{type(self).__name__}({self.owner!r}, {self.number!r}, {self._balance})"

    def __eq__(self, other):
        if not isinstance(other, Account):
            return NotImplemented
        return self.number == other.number

    def __len__(self):
        return len(self._transactions)


a = Account("Anvay", "SB-300", 500)
a.deposit(100)
a.deposit(250)

print(str(a))                 # uses __str__
print([a])                    # uses __repr__
print(f"Transactions: {len(a)}")  # uses __len__

same_number = Account("A Different Person", "SB-300", 0)
other_number = Account("Anvay", "SB-999", 500)
print(f"Same account number equal? {a == same_number}")
print(f"Different number equal?    {a == other_number}")

▶ Output

SB-300 (Anvay): Rs.850.00
[Account('Anvay', 'SB-300', 850.0)]
Transactions: 2
Same account number equal? True
Different number equal?    False

What happened here: __str__ gives a friendly one-line summary for humans, while __repr__ gives the developer-facing form you see inside a list, which is why [a] printed the constructor-style text. __eq__ decides two accounts are equal when their account numbers match, so a bank never treats SB-300 as two different accounts just because the owner name differs. Returning NotImplemented for non-accounts lets Python fall back gracefully instead of crashing. And __len__ makes len(a) report the transaction count. The magic methods tutorial lists the full set of these hooks.

Increment 4: The Bank (Composition)

An account does not know about other accounts, and it should not. Moving money between them is the Bank‘s job. This is composition rather than inheritance: a bank is not a kind of account, it has accounts. Think of it like a filing cabinet holding folders. The cabinet does not become a folder, it just stores and organizes them. Our Bank keeps accounts in a dictionary keyed by number, and its transfer does a withdraw then a deposit.

📄 run4_bank.py: a bank that owns accounts and moves money

class Bank:
    def __init__(self, name):
        self.name = name
        self._accounts = {}

    def open_account(self, account):
        if account.number in self._accounts:
            raise BankError(f"account {account.number} already exists")
        self._accounts[account.number] = account
        return account

    def get(self, number):
        try:
            return self._accounts[number]
        except KeyError:
            raise AccountNotFoundError(f"no account numbered {number}")

    def transfer(self, from_number, to_number, amount):
        source = self.get(from_number)
        target = self.get(to_number)
        source.withdraw(amount)   # if this raises, nothing has moved
        target.deposit(amount)

    def total_assets(self):
        return round(sum(a.balance for a in self._accounts.values()), 2)


bank = Bank("TechnoScripts")
bank.open_account(SavingsAccount("Aditi", "SB-001", 5000, rate=0.04))
bank.open_account(CheckingAccount("Aviraj", "CA-002", 1000, overdraft=500))

bank.transfer("SB-001", "CA-002", 1500)
print(f"After transfer -> {bank.get('SB-001')}")
print(f"After transfer -> {bank.get('CA-002')}")

try:
    bank.transfer("SB-001", "CA-002", 999999)
except InsufficientFundsError as e:
    print(f"Transfer refused: {e}")

print(f"Total assets still Rs.{bank.total_assets():,.2f}")

▶ Output

After transfer -> SB-001 (Aditi): Rs.3,500.00
After transfer -> CA-002 (Aviraj): Rs.2,500.00
Transfer refused: cannot withdraw 999999.00, balance is 3500.00
Total assets still Rs.6,000.00

What happened here: the transfer worked because withdraw succeeded first, then deposit ran. The refused transfer is the important part: since source.withdraw(amount) raised before target.deposit(amount) could run, no money was created out of thin air, and the bank’s total stayed at 6,000. That ordering, take from the source first and only then give to the target, is a small design decision that keeps the books honest. Notice the print lines reused the account’s __str__ from the previous step, so the Bank got clean output for free.

Increment 5: Saving to JSON

A bank that forgets everything on exit is useless. We persist to a JSON file, the same standard-library approach from the expense tracker project. The one twist here is that we have subclasses, so each account records its type when saved, and a small factory rebuilds the right class on load. It is like labelling boxes before you pack them, so unpacking puts each item back where it belongs.

📄 run5_persist.py: save the bank, then load it back

ACCOUNT_TYPES = {
    "Account": Account,
    "SavingsAccount": SavingsAccount,
    "CheckingAccount": CheckingAccount,
}


def account_from_dict(data):
    cls = ACCOUNT_TYPES.get(data["type"], Account)
    account = cls(data["owner"], data["number"])
    account._balance = data["balance"]
    account._transactions = [tuple(t) for t in data["transactions"]]
    return account


bank = Bank("TechnoScripts")
bank.open_account(SavingsAccount("Aditi", "SB-001", 5000, rate=0.04))
bank.open_account(CheckingAccount("Aviraj", "CA-002", 1000, overdraft=500))
bank.get("SB-001").withdraw(200)
bank.save("bank.json")

restored = Bank.load("bank.json")
sb = restored.get("SB-001")
print(f"Reloaded account type: {type(sb).__name__}")
print(f"Reloaded balance: Rs.{sb.balance:,.2f}, transactions: {len(sb)}")

sb.add_interest()  # proves the SavingsAccount behavior survived
print(f"Interest applied after reload: Rs.{sb.balance:,.2f}")

▶ Output

Reloaded account type: SavingsAccount
Reloaded balance: Rs.4,800.00, transactions: 2
Interest applied after reload: Rs.4,992.00

What happened here: saving turned each account into a plain dictionary carrying its type string, and account_from_dict read that string to pick the right class from the ACCOUNT_TYPES map. That is why the reloaded object is a genuine SavingsAccount, not a generic Account, so calling add_interest on it still works. Without the type label, a load would flatten every account into the base class and you would lose the savings and overdraft behavior.

One honest limitation: to_dict does not save rate or overdraft, so reloaded accounts fall back to the defaults (our demo used the default 0.04 rate, which hides that). Extending to_dict to carry them is a good exercise. The save and load methods themselves live on the Bank in the full program below.

The Full Program

Here are all the increments assembled into one file, about 170 lines. Save it as bank.py. This is the version that ticks every box on the acceptance list, including the save and load methods on Bank and a friendly __str__ for the bank itself.

📄 bank.py: the complete bank account manager

"""A small bank account manager: inheritance, magic methods, properties,
custom exceptions, and JSON persistence. Standard library only."""
import json
from pathlib import Path


# --- custom exceptions ---
class BankError(Exception):
    """Base error for anything the bank rejects."""


class InvalidAmountError(BankError):
    pass


class InsufficientFundsError(BankError):
    pass


class AccountNotFoundError(BankError):
    pass


# --- the base account ---
class Account:
    def __init__(self, owner, number, balance=0.0):
        self.owner = owner
        self.number = number
        self._balance = 0.0
        self._transactions = []
        if balance:
            self.deposit(balance)

    @property
    def balance(self):
        return self._balance

    def deposit(self, amount):
        if amount <= 0:
            raise InvalidAmountError(f"deposit must be positive, got {amount}")
        self._balance = round(self._balance + amount, 2)
        self._transactions.append(("deposit", round(amount, 2)))
        return self._balance

    def withdraw(self, amount):
        if amount <= 0:
            raise InvalidAmountError(f"withdrawal must be positive, got {amount}")
        if amount > self._balance:
            raise InsufficientFundsError(
                f"cannot withdraw {amount:.2f}, balance is {self._balance:.2f}")
        self._balance = round(self._balance - amount, 2)
        self._transactions.append(("withdraw", round(amount, 2)))
        return self._balance

    def to_dict(self):
        return {"type": type(self).__name__, "owner": self.owner,
                "number": self.number, "balance": self._balance,
                "transactions": self._transactions}

    def __str__(self):
        return f"{self.number} ({self.owner}): Rs.{self._balance:,.2f}"

    def __repr__(self):
        return f"{type(self).__name__}({self.owner!r}, {self.number!r}, {self._balance})"

    def __eq__(self, other):
        if not isinstance(other, Account):
            return NotImplemented
        return self.number == other.number

    def __len__(self):
        return len(self._transactions)


class SavingsAccount(Account):
    def __init__(self, owner, number, balance=0.0, rate=0.04):
        super().__init__(owner, number, balance)
        self.rate = rate

    def add_interest(self):
        interest = round(self._balance * self.rate, 2)
        self.deposit(interest)
        return interest


class CheckingAccount(Account):
    def __init__(self, owner, number, balance=0.0, overdraft=500.0):
        super().__init__(owner, number, balance)
        self.overdraft = overdraft

    def withdraw(self, amount):
        if amount <= 0:
            raise InvalidAmountError(f"withdrawal must be positive, got {amount}")
        limit = self._balance + self.overdraft
        if amount > limit:
            raise InsufficientFundsError(
                f"cannot withdraw {amount:.2f}, overdraft limit is {limit:.2f}")
        self._balance = round(self._balance - amount, 2)
        self._transactions.append(("withdraw", round(amount, 2)))
        return self._balance


# --- rebuild the right subclass from saved data ---
ACCOUNT_TYPES = {"Account": Account, "SavingsAccount": SavingsAccount,
                 "CheckingAccount": CheckingAccount}


def account_from_dict(data):
    cls = ACCOUNT_TYPES.get(data["type"], Account)
    account = cls(data["owner"], data["number"])
    account._balance = data["balance"]
    account._transactions = [tuple(t) for t in data["transactions"]]
    return account


# --- the bank owns accounts (composition) ---
class Bank:
    def __init__(self, name):
        self.name = name
        self._accounts = {}

    def open_account(self, account):
        if account.number in self._accounts:
            raise BankError(f"account {account.number} already exists")
        self._accounts[account.number] = account
        return account

    def get(self, number):
        try:
            return self._accounts[number]
        except KeyError:
            raise AccountNotFoundError(f"no account numbered {number}")

    def transfer(self, from_number, to_number, amount):
        source = self.get(from_number)
        target = self.get(to_number)
        source.withdraw(amount)
        target.deposit(amount)

    def total_assets(self):
        return round(sum(a.balance for a in self._accounts.values()), 2)

    def save(self, path):
        data = {"name": self.name,
                "accounts": [a.to_dict() for a in self._accounts.values()]}
        Path(path).write_text(json.dumps(data, indent=2), encoding="utf-8")

    @classmethod
    def load(cls, path):
        raw = json.loads(Path(path).read_text(encoding="utf-8"))
        bank = cls(raw["name"])
        for item in raw["accounts"]:
            bank.open_account(account_from_dict(item))
        return bank

    def __len__(self):
        return len(self._accounts)

    def __str__(self):
        return f"{self.name} bank with {len(self)} accounts, Rs.{self.total_assets():,.2f} total"

Every increment we ran earlier is a slice of this single file. To use it, run a demo script or the tests in the same folder so from bank import ... can find it. This is the version that ticks every box on the acceptance list.

Increment 6: A Starter Test File

How do you know the bank actually meets the acceptance criteria? You test it. Before pytest enters the picture later in the series, a plain file full of assert statements does the job. It is like a pre-flight checklist a pilot reads aloud: each line either passes quietly or stops everything with a clear failure. Save this next to bank.py as test_bank.py and run it.

📄 test_bank.py: acceptance criteria as runnable asserts

"""Assert-based tests for the bank manager. Run with: python test_bank.py
Later in the series this same file becomes a pytest suite."""
from bank import (Account, SavingsAccount, CheckingAccount, Bank,
                  InvalidAmountError, InsufficientFundsError, AccountNotFoundError)


def check(label, condition):
    assert condition, f"FAILED: {label}"
    print(f"  ok: {label}")


def raises(label, error_type, func):
    try:
        func()
    except error_type:
        print(f"  ok: {label}")
    else:
        raise AssertionError(f"FAILED: {label} did not raise {error_type.__name__}")


print("Account basics")
a = Account("Anvi", "SB-1", 100)
a.deposit(50)
check("deposit updates balance", a.balance == 150)
a.withdraw(30)
check("len counts transactions", len(a) == 3)
raises("negative deposit rejected", InvalidAmountError, lambda: a.deposit(-1))
raises("overdraw rejected", InsufficientFundsError, lambda: a.withdraw(10000))
raises("balance is read-only", AttributeError, lambda: setattr(a, "balance", 0))

print("Subclasses")
s = SavingsAccount("Aditi", "SB-2", 1000, rate=0.10)
check("interest added", s.add_interest() == 100 and s.balance == 1100)
c = CheckingAccount("Aviraj", "CA-1", 100, overdraft=200)
c.withdraw(250)
check("overdraft allowed within limit", c.balance == -150)
raises("overdraft floor enforced", InsufficientFundsError, lambda: c.withdraw(1000))

print("Equality and the Bank")
check("equal by account number", Account("X", "SB-1") == a)
bank = Bank("Test")
bank.open_account(s)
bank.open_account(c)
bank.transfer("SB-2", "CA-1", 100)
check("transfer moves money", bank.get("SB-2").balance == 1000 and bank.get("CA-1").balance == -50)
raises("unknown account", AccountNotFoundError, lambda: bank.get("NOPE"))

print("\nAll tests passed.")

▶ Output

Account basics
  ok: deposit updates balance
  ok: len counts transactions
  ok: negative deposit rejected
  ok: overdraw rejected
  ok: balance is read-only
Subclasses
  ok: interest added
  ok: overdraft allowed within limit
  ok: overdraft floor enforced
Equality and the Bank
  ok: equal by account number
  ok: transfer moves money
  ok: unknown account

All tests passed.

What happened here: the two helpers, check and raises, turn every acceptance criterion into a single readable line. check asserts a condition is true, and raises asserts that a piece of code blows up with the exact error type you expect, which is how you test that bad input is rejected. Every line printed ok, so the bank does what we promised in the checklist. When you reach the pytest tutorial, each of these becomes a test_ function and the framework reports pass or fail for you.

Stretch Goals

The manager works, but a Python OOP project you keep poking at is a project you learn from. Each of these is a self-contained upgrade that exercises something from earlier in the series.

  • Transaction history: add a statement() method that prints every deposit and withdrawal with a running balance, using the data already sitting in _transactions.
  • Timestamps: store the date on each transaction with datetime, then let the bank report all activity in a given month.
  • A new account type: add a FixedDeposit that blocks withdrawals until a maturity date, another clean subclass that overrides one method.
  • Exact money: swap float for decimal.Decimal so the arithmetic is exact to the paisa, the correct choice for anything touching real money.

One more thing worth doing: put this project under version control. A single bank.py with its test file is the perfect first repository, small enough to understand fully and real enough to care about. When you reach the Git block next in the series, this is the code you will track.

Common Mistakes

Mistake 1: Reaching for inheritance when composition fits better

A common beginner move is to make Bank inherit from Account because both deal with money. That is wrong: a bank is not a kind of account, it holds accounts. The quick test is the phrase “is a” versus “has a”. A savings account is an account, so inheritance fits. A bank has accounts, so composition fits. Getting this right keeps your classes honest and your code easy to follow.

Mistake 2: Forgetting super().__init__ in a subclass

If SavingsAccount.__init__ sets self.rate but forgets to call super().__init__(...), the base class never runs, so self._balance and self._transactions never get created. The first deposit then crashes with an AttributeError. Whenever a subclass defines its own __init__, call the parent’s first, then add your extra fields.

Mistake 3: Doing the deposit before the withdraw in a transfer

If transfer credits the target before debiting the source, a failed withdraw leaves money that was never really there. Always take from the source first, so that if it cannot pay, the whole operation stops before anything is added. Order matters when two steps have to succeed together.

Best Practices

  • DO write your acceptance criteria first, then build until every box is ticked
  • DO guard the balance behind a read-only @property so it can only change through deposit and withdraw
  • DO raise a small family of custom exceptions, so callers can catch the base BankError or a specific type
  • DO store a type label when saving subclasses, so loading rebuilds the correct class
  • DON’T use inheritance for a “has a” relationship, reach for composition instead
  • DON’T use plain float for real financial software, use decimal.Decimal for exact arithmetic

Conclusion

You just finished a complete Python OOP project, a bank account manager where every part was a skill you already had: a @property to protect the balance, inheritance for the account types, magic methods to make objects behave like built-ins, composition to let the bank own its accounts, custom exceptions to reject bad input, and JSON to make it all survive a restart. That is the real answer to “when would I create a class?” You create one when you have a thing with its own data and its own rules, and you create a family of them when those things share behavior but differ in the details.

From here, try the stretch goals, then rebuild something similar from memory, which is when the design really sticks. The natural next level is recognizing the named solutions other engineers already rely on, and the Python design patterns guide catalogs them with runnable code. And if you want to see everything this series covers, from first steps to AI and machine learning, browse the Python + AI/ML tutorial series home.

Frequently Asked Questions

What makes this a good Python OOP project for practice?

It uses every core object-oriented tool in one place: inheritance for account types, magic methods for natural behavior, a property to protect the balance, custom exceptions for validation, and composition for the bank. Building a Python OOP project end to end shows how the pieces fit, which isolated exercises cannot.

When should I use inheritance versus composition here?

Use inheritance for an ‘is a’ relationship: a SavingsAccount is an Account, so it inherits. Use composition for a ‘has a’ relationship: a Bank has accounts, so it holds them in a dictionary rather than inheriting from Account.

Why store the account type when saving to JSON?

JSON has no concept of Python classes, so on load every account would become a plain Account and lose its subclass behavior. Saving a ‘type’ string lets a small factory rebuild the correct SavingsAccount or CheckingAccount, keeping interest and overdraft logic intact.

Do I need any libraries to build this bank manager?

No. It uses only the standard library: json and pathlib. There is nothing to install, so the code will keep running for years without dependency rot.

Is float safe for the balances in this project?

For a learning project, yes, and we round to two decimals after each operation. For real financial software, use decimal.Decimal, which does exact base-ten arithmetic and avoids the tiny drift floats can introduce.

Interview Questions on This Project

Interviewers rarely ask for definitions. They ask what happens in situations like these.

Q: Why is the balance a read-only property instead of a plain attribute?

A plain balance attribute could be set to any value by any line of code, including a negative one, bypassing all the rules. A getter-only @property makes account.balance = x raise AttributeError, so the only paths to change it are deposit and withdraw, which both validate. The real value lives in self._balance, an ordinary attribute the property reads.

Q: CheckingAccount overrides withdraw. How does Python know to call the subclass version?

Through the method resolution order. When you call checking.withdraw(...), Python looks up withdraw starting at the object’s actual class, finds it on CheckingAccount, and stops. The base Account.withdraw is never reached for a checking account. This is runtime polymorphism: the same call resolves to different code depending on the object’s real type.

Q: Why does __eq__ return NotImplemented instead of False for a non-account?

Returning NotImplemented tells Python “I do not know how to compare these”, so it tries the other object’s __eq__ before giving up and deciding they are unequal. Returning False outright would short-circuit that fallback and could give surprising results when comparing an account with a different type that does know how to compare itself.

Q: You defined __eq__. Why might you also need __hash__?

Defining __eq__ makes Python set __hash__ to None, so instances become unhashable and cannot be used as dictionary keys or set members. If you need accounts in a set or as keys, add a __hash__ that is consistent with equality, for example return hash(self.number), so equal accounts share a hash.

Q: The transfer does a withdraw then a deposit. What real-world failure does the ordering prevent, and what does it still not handle?

Withdrawing first means a failed debit stops the transfer before any credit happens, so money is never created. What it still does not handle is a crash between the two steps, which would debit the source without crediting the target. Solving that needs a transaction or rollback mechanism, the kind a database provides, which is exactly why banks use them.

Q: How would you extend this to a new account type without touching existing code?

Add a subclass of Account that overrides only what differs, then register it in the ACCOUNT_TYPES map so persistence can rebuild it. The Bank, the transfer logic, and the magic methods all work unchanged because they only rely on the Account interface. That is the open-closed idea: open to extension through new subclasses, closed to modification of the code that already works.

Go deeper: when you outgrow this post, the official Python documentation is the next stop.

Previous: Python: Property Decorators, @property, getter, setter

Next: Git Basics for Python Developers: Commits, Diffs, and Undo

Series Home: Python + AI/ML Tutorial Series

RahulAuthor posts

Avatar for Rahul

Rahul is a passionate IT professional who loves to sharing his knowledge with others and inspiring them to expand their technical knowledge. Rahul's current objective is to write informative and easy-to-understand articles to help people avoid day-to-day technical issues altogether. Follow Rahul's blog to stay informed on the latest trends in IT and gain insights into how to tackle complex technical issues. Whether you're a beginner or an expert in the field, Rahul's articles are sure to leave you feeling inspired and informed.

No comment

Leave a Reply

Your email address will not be published. Required fields are marked *