Python CLI Mini Project: Build an Expense Tracker from Scratch
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.
argparsecommands such asexpense 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:
- Web development with Build a Website with Flask.
- Modern APIs with FastAPI Tutorial for Beginners: Setup and First API.
- Automation scripts for daily work.
- Data and machine learning through Machine Learning with Python.
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.
Related Articles
Keep reading within the same topic.
Python Fundamentals Learning Path: Series Recap and Next Steps
A practical recap of the Python Fundamentals series, with six lessons, key skills, checkpoints, and clear next steps after building the CLI project.
Python Project Structure: venv, pip, Requirements, and Testing
Learn how to organize a Python project with folders, virtual environments, pip, requirements.txt, .env files, pytest, and a simple entry point.
Object-Oriented Python for Beginners: Classes, Objects, and Dataclasses
Learn practical Python OOP: classes, objects, methods, __init__, dataclasses, composition, and when object-oriented code actually helps.
Python Files, Errors, and Modules: Writing Cleaner Scripts
Learn how to read files, write files, handle errors, create modules, import your own code, and organize cleaner Python scripts.