Python CLI Mini Project: Build an Expense Tracker from Scratch

Python By TryzTech Team
PythonCLIMini ProjectBeginnerTutorial

In the previous five articles, you learned the Python foundation: variables, control flow, functions, data structures, files, error handling, modules, OOP, project structure, virtual environments, and testing.

Now it is time to combine everything into a small usable project: a CLI expense tracker. This app runs in the terminal, adds expenses, lists expenses, and calculates the total.

Project goal

The minimal features are:

  • Add expense.
  • List expenses.
  • Show total.
  • Exit.
  • Save data to a JSON file.
  • Test the total calculation.

Example flow:

1. Add expense
2. List expenses
3. Show total
4. Exit
Choose: 1
Title: Coffee
Amount: 25000
Saved.

This project is intentionally small. The goal is to make the fundamental concepts feel real.

Build it in stages. Make the first version display a menu and exit cleanly, then add the data model, JSON storage, and tests. This order makes each error easier to trace because you know which feature was added most recently.

Folder structure

Use this structure:

expense-tracker/
  src/
    expense_tracker/
      __init__.py
      app.py
      models.py
      storage.py
      main.py
  tests/
    test_app.py
  requirements.txt
  README.md

Each part has a different responsibility: models.py defines the expense shape, storage.py handles JSON, app.py contains the logic, and main.py is the program entry point. When a rule changes, this separation helps you find the relevant file quickly.

Create a virtual environment and install pytest:

python -m venv .venv
source .venv/bin/activate
pip install pytest
pip freeze > requirements.txt

On Windows PowerShell, activate .venv with:

.\.venv\Scripts\Activate.ps1

Data model with dataclass

Create src/expense_tracker/models.py:

# File: models.py
from dataclasses import dataclass

@dataclass
class Expense:
    title: str
    amount: int

    def to_dict(self):
        return {
            "title": self.title,
            "amount": self.amount,
        }

Expense stores spending data. The to_dict() method will be used when saving data as JSON.

Core logic

Create src/expense_tracker/app.py:

# File: app.py
from expense_tracker.models import Expense

def calculate_total(expenses):
    return sum(expense.amount for expense in expenses)

def format_currency(amount):
    return f"IDR {amount:,}".replace(",", ".")

def create_expense(title, amount_text):
    amount = int(amount_text)

    if amount <= 0:
        raise ValueError("Amount must be greater than zero")

    return Expense(title=title.strip(), amount=amount)

create_expense() receives amount_text because terminal input is always a string. If the input is invalid, the function raises ValueError.

JSON storage

Create src/expense_tracker/storage.py:

# File: storage.py
import json
from pathlib import Path

from expense_tracker.models import Expense

def load_expenses(path):
    file_path = Path(path)

    if not file_path.exists():
        return []

    with file_path.open("r", encoding="utf-8") as file:
        data = json.load(file)

    return [
        Expense(title=item["title"], amount=item["amount"])
        for item in data
    ]

def save_expenses(path, expenses):
    file_path = Path(path)
    data = [expense.to_dict() for expense in expenses]

    with file_path.open("w", encoding="utf-8") as file:
        json.dump(data, file, indent=2)

JSON is easy to inspect and enough for a beginner project. Real applications may use databases, but do not jump there too early.

CLI loop

Create src/expense_tracker/main.py:

# File: main.py
from expense_tracker.app import (
    calculate_total,
    create_expense,
    format_currency,
)
from expense_tracker.storage import load_expenses, save_expenses

DATA_FILE = "expenses.json"

def print_menu():
    print()
    print("1. Add expense")
    print("2. List expenses")
    print("3. Show total")
    print("4. Exit")

def add_expense(expenses):
    title = input("Title: ")
    amount_text = input("Amount: ")

    try:
        expense = create_expense(title, amount_text)
    except ValueError as error:
        print(f"Invalid input: {error}")
        return

    expenses.append(expense)
    save_expenses(DATA_FILE, expenses)
    print("Saved.")

def list_expenses(expenses):
    if not expenses:
        print("No expenses yet.")
        return

    for index, expense in enumerate(expenses, start=1):
        amount = format_currency(expense.amount)
        print(f"{index}. {expense.title} - {amount}")

def show_total(expenses):
    total = calculate_total(expenses)
    print(f"Total: {format_currency(total)}")

def main():
    expenses = load_expenses(DATA_FILE)

    while True:
        print_menu()
        choice = input("Choose: ")

        if choice == "1":
            add_expense(expenses)
        elif choice == "2":
            list_expenses(expenses)
        elif choice == "3":
            show_total(expenses)
        elif choice == "4":
            print("Bye.")
            break
        else:
            print("Unknown option.")

if __name__ == "__main__":
    main()

Run it from the project root:

python -m expense_tracker.main

If imports are not found because of the src/ structure, run with PYTHONPATH:

PYTHONPATH=src python -m expense_tracker.main

On Windows PowerShell:

$env:PYTHONPATH="src"
python -m expense_tracker.main

Testing total logic

Create tests/test_app.py:

# File: test_app.py
from expense_tracker.app import calculate_total, create_expense
from expense_tracker.models import Expense

def test_calculate_total():
    expenses = [
        Expense("Coffee", 25_000),
        Expense("Lunch", 50_000),
    ]

    assert calculate_total(expenses) == 75_000

def test_create_expense():
    expense = create_expense("Coffee", "25000")

    assert expense.title == "Coffee"
    assert expense.amount == 25_000

Run:

PYTHONPATH=src pytest

This test is small, but valuable. It protects the total calculation and input parsing as the project changes.

Notice that the test does not run the interactive menu. Logic such as calculate_total() and create_expense() lives in separate functions so a test can call them directly. That is why the functions and structure from the previous articles matter in a real project.

Improvement ideas

After the minimal version works, try adding:

  • Delete expense.
  • Edit expense.
  • Category.
  • Filter by category.
  • CSV export.
  • Monthly summary.
  • argparse commands such as expense add.

Add features one by one. Do not build everything at once.

Common mistakes

The first mistake is placing all code in main.py. It may work for a small project, but it becomes hard to maintain.

The second mistake is mixing terminal input with calculation logic. Keep logic such as calculate_total() separate so it can be tested.

The third mistake is not handling invalid input. Users can type empty text, negative numbers, or letters in the amount field.

The fourth mistake is reaching for a database too early. For learning fundamentals, a JSON file is enough.

Completion checklist

Test the project with a missing JSON file, an empty list, a zero amount, letters in the amount field, and several expenses. Each case should produce behavior you can explain rather than a confusing traceback.

Then review the module boundaries. If the menu contains too much calculation logic, move that logic into functions in app.py. If storage.py starts handling terminal input, move that work back to main.py. Small boundaries like these keep the project easier to extend.

Closing the series

If you finish this project, you have completed a strong fundamentals path. You did not only read Python syntax; you built a small program with structure, file storage, error handling, and tests.

From here, your next path can vary:

The fundamentals you used in this project will show up in all of those paths.

If your goal is to become a Python backend developer, the most natural next path after this series is FastAPI. You will reuse functions, dictionaries, modules, virtual environments, dependencies, error handling, and testing, but the context changes from a terminal app into an API that frontend apps or other services can call. Start with the setup article, then follow the sequence through Learn FastAPI from Scratch: Complete Beginner Learning Path.

For the full lesson map, key skills, and a final practice checkpoint, visit the Python Fundamentals Learning Path.

Keep reading within the same topic.

Don't Miss Out

Get the latest tech articles, tips, and insights delivered to your inbox.