Build a Python expense tracker CLI in 60 lines (with tests)
A small project that teaches argparse, file handling, dictionaries and testing. The code below was run and tested before publishing.

This project is small enough to finish in an evening and teaches things you will use in real code: command-line arguments, reading and writing files, dictionaries, error handling and a unit test. It uses only Python's standard library, so there is nothing to install. The code and the sample output below were run with Python 3 before this article was published.
What we are building
A command-line tool with three commands:
addan expense (category, amount, optional note).listall expenses.summarythe total per category, largest first.
Data is stored in a plain CSV file, so you can also open it in a spreadsheet.
The code
Save this as expenses.py:
#!/usr/bin/env python3
"""A tiny expense tracker: add expenses, list them and show totals per category."""
import argparse
import csv
from collections import defaultdict
from datetime import date
from pathlib import Path
FILE = Path("expenses.csv")
FIELDS = ["date", "category", "amount", "note"]
def load():
if not FILE.exists():
return []
with FILE.open(newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
def add(category, amount, note=""):
is_new = not FILE.exists()
with FILE.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
if is_new:
writer.writeheader()
writer.writerow({"date": date.today().isoformat(), "category": category,
"amount": f"{amount:.2f}", "note": note})
def summary(rows):
totals = defaultdict(float)
for row in rows:
totals[row["category"]] += float(row["amount"])
return dict(sorted(totals.items(), key=lambda item: item[1], reverse=True))
def main(argv=None):
parser = argparse.ArgumentParser(description="Simple expense tracker")
sub = parser.add_subparsers(dest="command", required=True)
p_add = sub.add_parser("add", help="add an expense")
p_add.add_argument("category")
p_add.add_argument("amount", type=float)
p_add.add_argument("--note", default="")
sub.add_parser("list", help="list all expenses")
sub.add_parser("summary", help="total per category")
args = parser.parse_args(argv)
if args.command == "add":
if args.amount <= 0:
parser.error("amount must be greater than zero")
add(args.category, args.amount, args.note)
print(f"Added {args.amount:.2f} to {args.category}")
elif args.command == "list":
for row in load():
print(f'{row["date"]} {row["category"]:<10} {float(row["amount"]):>8.2f} {row["note"]}')
else:
rows = load()
for category, total in summary(rows).items():
print(f"{category:<10} {total:>8.2f}")
print(f'{"TOTAL":<10} {sum(float(r["amount"]) for r in rows):>8.2f}')
if __name__ == "__main__":
main()
Try it
$ python3 expenses.py add food 120.5 --note "lunch"
Added 120.50 to food
$ python3 expenses.py add travel 300
Added 300.00 to travel
$ python3 expenses.py add food 80
Added 80.00 to food
$ python3 expenses.py list
2026-10-27 food 120.50 lunch
2026-10-27 travel 300.00
2026-10-27 food 80.00
$ python3 expenses.py summary
travel 300.00
food 200.50
TOTAL 500.50
$ python3 expenses.py add food -5
usage: expenses.py [-h] {add,list,summary} ...
expenses.py: error: amount must be greater than zero
(The date will be today's date on your machine.)
How it works
argparse turns the command line into structured data. add_subparsers gives us separate commands, each with its own arguments, and it generates --help for free. type=float converts the amount and rejects text such as abc.
csv.DictWriter and DictReader read and write rows as dictionaries keyed by the header names, which is clearer than numbered columns. We write the header only when the file is new.
with open(...) closes the file automatically, even if an error happens. We pass newline="" as the csv module requires, and an explicit encoding.
defaultdict(float) creates a missing key with 0.0 the first time we use it, so adding totals needs no if key in dict checks.
sorted(..., key=lambda item: item[1], reverse=True) orders categories by total, largest first.
Validation: parser.error(...) prints a usage message and exits with code 2, the standard for command-line usage errors.
Add a test
Notice that summary is a pure function: rows in, totals out, no files and no printing. Pure functions are easy to test. Save as test_expenses.py:
import unittest
import expenses
class SummaryTest(unittest.TestCase):
def test_groups_and_sorts(self):
rows = [
{"category": "food", "amount": "10.50"},
{"category": "travel", "amount": "40"},
{"category": "food", "amount": "4.50"},
]
self.assertEqual(expenses.summary(rows), {"travel": 40.0, "food": 15.0})
if __name__ == "__main__":
unittest.main()
Run it with the built-in test runner:
$ python3 -m unittest -v
...
Ran 1 test in 0.000s
OK
Known limits (good things to improve)
- Money as floating-point numbers can show rounding errors. For real accounting, use the
decimalmodule or store whole cents as integers. - It always uses the file in the current folder. Make the path configurable with an option or an environment variable.
- Two programs writing at once could interleave rows. A database such as SQLite would be safer.
- No way to edit or delete an expense yet.
Challenges to extend it
- Add a
--month 2026-10filter tosummary. - Add a
deletecommand by row number. - Add a budget limit per category and warn when it is exceeded.
- Export a monthly report to a text or HTML file.
- Store data in SQLite instead of CSV (the Python standard library includes
sqlite3). - Package it so you can run
expenses add ...from anywhere.
Put it on GitHub with a README that shows the commands above. It makes a good first portfolio project.
Takeaway: a useful tool needs only argparse, a CSV file and a few small functions. Keep logic in pure functions so it is easy to test, and extend the project with the challenges above.
Build-along projects series, Part 1 (small tested projects): 1 of 5.


