April 18, 2025
Python Beginner Project CLI

Build a CLI Expense Tracker: Your First Python Project

You've learned variables, functions, string manipulation, loops, error handling, and how to import modules from the Python standard library. You've been doing the work, writing snippets, running examples, getting comfortable with the syntax. And if you've stuck with this series through nine articles, you know the fundamentals cold. But knowing is different from doing, and doing is different from building. Now comes the moment of truth: building something real. Not a code snippet. Not a classroom exercise. A working tool that solves an actual problem, tracking your expenses from the command line.

This is your Cluster 1 capstone. Every concept you've learned so far, from basic variables to error handling to standard library modules, gets woven together here into a single coherent application. We're going to build a CLI Expense Tracker that ties together everything, and we're going to build it the right way: with proper structure, separation of concerns, and defensive input handling. By the end, you'll have a fully functional application that persists data between runs, validates user input, and formats output beautifully. More importantly, you'll understand how to structure a Python project like a professional developer, not just someone who writes scripts that happen to work.

The jump from "I can write Python code" to "I can build Python software" is exactly the jump this project is designed to give you. So sharpen up, follow along, and let's build something worth using.

Table of Contents
  1. Why Build a CLI Project?
  2. Project Planning Mindset
  3. CLI Design Principles
  4. Project Architecture: Three Layers
  5. Data Persistence Choices
  6. Step 1: Setting Up the Project
  7. Step 2: The Data Layer, Reading and Writing JSON
  8. Step 3: The Logic Layer, Adding and Filtering Expenses
  9. Step 4: The CLI Layer, argparse and User Interaction
  10. Step 5: Testing Your CLI Tool
  11. What Cluster 1 Concepts Did We Use?
  12. Extension Ideas
  13. How to Extend This (Spoiler: Lists Are the Key)
  14. Next Steps: Welcome to Cluster 2
  15. Final Thoughts

Why Build a CLI Project?

Before we code, let's talk about why this matters. You could learn Python forever by running isolated scripts in an interpreter. But real development means understanding how pieces fit together: how to separate concerns, handle user input, persist data, and gracefully recover from errors.

A CLI tool is perfect for this. It's:

  • Simple enough to finish in one session
  • Complex enough to touch every Cluster 1 concept
  • Practical, you can actually use it
  • Extensible, you can add features as your skills grow

By building this, you're thinking like a real developer, not just a syntax learner.

Project Planning Mindset

Here's the thing nobody tells beginners: professional developers don't open a blank file and start typing code. They think first. They ask questions like "What problem does this solve?", "Who uses this?", "What are the failure modes?", and "How will this grow?" before a single line of Python gets written. This planning mindset is what separates throwaway scripts from software that actually holds up.

For our expense tracker, the planning phase answers a few key questions quickly. The problem is simple: you want to record what you spent, where you spent it, and be able to look back at your spending patterns. The users are you and anyone who runs this tool from a terminal. The failure modes include corrupted data files, negative amounts, empty inputs, and missing files. The growth path includes things like date filtering, CSV export, monthly reports, and eventually a web interface or database backend.

Once you've answered those questions, the design practically writes itself. You need a way to add expenses, list them, and summarize them. You need the data to survive between program runs, which means file persistence. You need to handle bad inputs without crashing. And you need a command structure that feels natural to type. None of these are code decisions yet, they're requirements, and gathering them before you start is what makes your code purposeful rather than accidental.

This mindset applies to every project you'll ever build, whether it's a CLI tool, a REST API, or a machine learning pipeline. Plan what you're building and why before you build it. The planning phase costs minutes; fixing unplanned architecture costs hours.

CLI Design Principles

Command-line interfaces have a design language all their own, and learning it will serve you for your entire career. A well-designed CLI follows predictable conventions that users already know from tools like git, npm, and curl. A poorly designed CLI fights users at every turn, even if the underlying logic is solid.

The most important CLI design principle is discoverability: a user who has never seen your tool should be able to figure out how to use it from the built-in help alone. This is exactly what argparse gives you for free, run any command with --help and get a structured description of all available options. The second principle is consistency: subcommands should follow the same patterns, arguments should behave predictably, and error messages should be actionable. When something goes wrong, tell the user what happened and ideally what to do about it.

The third principle is the principle of least surprise: your tool should do what the user expects, not what's technically convenient for you to implement. This means verbs for commands (add, list, summary, total), optional flags for filtering (--category), and positional arguments for required inputs. A command like python main.py add Food 12.50 "Lunch" reads almost like English, which is exactly the goal. Design your CLI so that reading it out loud makes sense, and you'll rarely go wrong.

Project Architecture: Three Layers

Before we write a single line of code, let's think about structure. Professional Python projects separate concerns into distinct layers:

  1. Data Layer, Handles reading/writing to disk (JSON file)
  2. Logic Layer, Functions that add, filter, and process expenses
  3. CLI Layer, Parses arguments and displays output to the user

This separation makes your code testable, maintainable, and reusable. If you later want to build a web interface or mobile app, your logic layer stays the same, only the UI layer changes.

expense_tracker/
├── main.py          # CLI layer (entry point)
├── expense.py       # Data & logic layers combined (simplified for this project)
└── expenses.json    # Persistent data file (created at runtime)

We'll keep this simple (two files) so you can understand the pattern without drowning in complexity.

Data Persistence Choices

Before we write the data layer, it's worth understanding why we chose JSON, because there are several other options, and knowing the tradeoffs makes you a better decision maker. Beginners often default to whatever they're shown without understanding why, which leads to picking the wrong tool for the next project.

JSON is the right choice here for three reasons. First, it's human-readable: you can open expenses.json in any text editor and immediately understand what's in it, which is invaluable when debugging. Second, it's built into Python's standard library, no installation required, no external dependencies to manage. Third, it handles nested structures naturally, so a list of expense dictionaries maps cleanly to JSON arrays of objects. The tradeoff is that JSON doesn't scale well to thousands of records and doesn't support queries, but for a personal expense tracker, you'll never hit those limits.

The alternatives worth knowing about are CSV files (simpler but no nesting), SQLite (a proper database built into Python's stdlib via the sqlite3 module, great for querying), and plain text (simple but you have to invent your own format). If you were building a multi-user expense tracker or needed to query expenses by date range efficiently, SQLite would be the right call. If you needed to export to Excel, CSV would win. For our single-user, read-everything approach, JSON is the sweet spot. Understanding these tradeoffs, not just memorizing what to use, is what makes a developer genuinely good at system design.

Step 1: Setting Up the Project

Create a folder called expense_tracker on your desktop or in a projects folder. Inside, create two empty files: main.py and expense.py. This is also a good moment to open a terminal and navigate to this folder, since you'll be running commands from here throughout the project.

bash
mkdir expense_tracker
cd expense_tracker
touch main.py expense.py

Your terminal should show:

expense_tracker/
├── main.py
└── expense.py

Great. Now let's build the data layer first.

Step 2: The Data Layer, Reading and Writing JSON

The data layer handles persistence. Every time a user adds an expense, we need to save it to a file so it's there when they run the program again. We'll use JSON because it's human-readable and built into Python's json module (no external dependencies).

Think of load_expenses() and save_expenses() as the only two functions in the entire project that are allowed to touch the filesystem. Everything else in expense.py works with in-memory data, pure Python lists and dictionaries. This boundary is critical for maintainability: if you ever switch from JSON files to a database, you only touch these two functions, and the rest of your code stays unchanged.

Open expense.py and write this:

python
import json
import os
from datetime import datetime
 
# File where we store all expenses
DATA_FILE = "expenses.json"
 
def load_expenses():
    """Load expenses from the JSON file. Return empty list if file doesn't exist."""
    if not os.path.exists(DATA_FILE):
        return []
 
    try:
        with open(DATA_FILE, "r") as f:
            return json.load(f)
    except json.JSONDecodeError:
        # If the file is corrupted, return empty list
        return []
 
def save_expenses(expenses):
    """Save expenses list to the JSON file."""
    with open(DATA_FILE, "w") as f:
        json.dump(expenses, f, indent=2)

Notice how we handle two failure scenarios proactively. The first is a missing file, normal on first run, handled by returning an empty list. The second is a corrupted file, less common, but possible if the program crashed mid-write. Rather than letting json.JSONDecodeError crash the whole application, we gracefully return an empty list. This is the defensive programming mindset in action: assume things will go wrong, and plan for them explicitly.

Let's walk through this:

Line 1-2: We import json (to read/write JSON) and os (to check if files exist).

Line 4: DATA_FILE is a constant (all caps by convention). It's the filename where expenses live.

Lines 7-13: The load_expenses() function is the heart of data persistence. It checks if the file exists using os.path.exists(). If not, we return an empty list (no expenses yet). If it does exist, we open it in read mode ("r"), and json.load() parses it into a Python list. Notice the try/except, if the JSON file is somehow corrupted, we don't crash; we gracefully return an empty list.

Lines 16-18: The save_expenses() function does the opposite: it takes our expenses list and writes it to the JSON file using json.dump(). The indent=2 parameter makes the JSON pretty (indented), so if you open the file in a text editor, it's readable.

Now let's test this locally. At the bottom of expense.py, add:

python
# Quick test (we'll remove this later)
if __name__ == "__main__":
    # Save some test data
    test_expenses = [
        {"date": "2025-02-20", "category": "Food", "amount": 12.50, "description": "Lunch"},
        {"date": "2025-02-21", "category": "Gas", "amount": 45.00, "description": "Fill-up"}
    ]
    save_expenses(test_expenses)
 
    # Load it back
    loaded = load_expenses()
    print("Loaded expenses:", loaded)

The if __name__ == "__main__": guard is a Python idiom you'll use constantly. It means "only run this code when this file is executed directly, not when it's imported by another module." This lets you write test code inside a module file without polluting the namespace when you import it elsewhere, exactly the kind of clean boundary we want between our layers.

Run it:

bash
python expense.py

Expected output:

Loaded expenses: [{'date': '2025-02-20', 'category': 'Food', 'amount': 12.50, 'description': 'Lunch'}, {'date': '2025-02-21', 'category': 'Gas', 'amount': 45.00, 'description': 'Fill-up'}]

Great! A file called expenses.json now exists in your folder with the test data inside. Let's verify by opening it (or running cat expenses.json on macOS/Linux):

json
[
  {
    "date": "2025-02-20",
    "category": "Food",
    "amount": 12.5,
    "description": "Lunch"
  },
  {
    "date": "2025-02-21",
    "category": "Gas",
    "amount": 45.0,
    "description": "Fill-up"
  }
]

Notice that 12.50 became 12.5 in the JSON output, Python's json.dump() strips trailing zeros from floats because they're mathematically identical. When you display the amount to users, you'll format it as $12.50 explicitly using f-string formatting. The storage format and the display format are separate concerns, and keeping them separate lets each one be optimized for its purpose.

Remove the test code from expense.py now (lines starting with if __name__ == "__main__" and below). We only needed it to verify the data layer works.

Step 3: The Logic Layer, Adding and Filtering Expenses

Now let's add functions to manipulate expenses. These functions don't care how the data gets there, they just work with in-memory lists. This is the power of separation: the logic is decoupled from storage. A function like get_total_by_category() has no idea whether the expenses came from a JSON file, a database, or a made-up list in a test, and it shouldn't care.

Add this to expense.py (above the test code if you haven't deleted it yet):

python
def add_expense(expenses, category, amount, description):
    """Add a new expense to the list."""
    if amount <= 0:
        raise ValueError("Amount must be positive")
 
    expense = {
        "date": datetime.now().strftime("%Y-%m-%d"),
        "category": category.strip(),
        "amount": float(amount),
        "description": description.strip()
    }
    expenses.append(expense)
    return expenses
 
def get_expenses_by_category(expenses, category):
    """Filter expenses by category (case-insensitive)."""
    category_lower = category.lower()
    return [e for e in expenses if e["category"].lower() == category_lower]
 
def get_total_spent(expenses):
    """Calculate total spending across all expenses."""
    return sum(e["amount"] for e in expenses)
 
def get_total_by_category(expenses):
    """Return a dictionary of total spending per category."""
    totals = {}
    for expense in expenses:
        cat = expense["category"]
        totals[cat] = totals.get(cat, 0) + expense["amount"]
    return totals

Pay attention to how add_expense() validates its inputs before doing anything with them. Validation at the boundary, right where data enters your system, is one of the most important habits you can build. It means invalid data never makes it into your storage, your summaries are always accurate, and your error messages appear close to the source of the problem rather than somewhere deep in the call stack.

Let's break this down:

add_expense() function:

  • Validates that amount is positive (raising a ValueError if not, error handling!)
  • Creates a dictionary for the new expense with today's date (using datetime.now())
  • Calls .strip() on strings to remove leading/trailing whitespace
  • Converts amount to a float (defensive programming)
  • Appends the expense to the list
  • Returns the updated list

get_expenses_by_category() function:

  • Takes a category and filters the expenses list
  • Compares categories case-insensitively (so "Food", "food", and "FOOD" all match)
  • Uses a list comprehension, a compact loop that creates a new filtered list in one line

get_total_spent() function:

  • Uses Python's built-in sum() function with a generator expression to sum all amounts
  • One line, super clean

get_total_by_category() function:

  • Builds a dictionary where keys are category names and values are totals
  • Uses .get(cat, 0) to safely get the current total (defaulting to 0 if the key doesn't exist yet)
  • This is a loop building a more complex data structure

These are pure functions, they don't depend on files or user input. That makes them testable and reusable. Perfect.

Step 4: The CLI Layer, argparse and User Interaction

Now for the fun part: the command-line interface. This is where main.py comes in. It's the only file that touches the filesystem and talks to the user. Notice that the CLI layer's job is narrow: parse arguments, call the right logic functions, format the results, and print them. It doesn't compute anything itself. That division of responsibility is exactly what makes the logic layer so easy to test and reuse.

Open main.py and write:

python
import argparse
import sys
from expense import load_expenses, save_expenses, add_expense, get_expenses_by_category, get_total_spent, get_total_by_category
 
def format_expenses_table(expenses):
    """Format a list of expenses as a pretty table."""
    if not expenses:
        return "No expenses to display."
 
    # Headers and column widths
    headers = ["Date", "Category", "Amount", "Description"]
    col_widths = [12, 15, 10, 30]
 
    # Build the table
    lines = []
    lines.append("-" * sum(col_widths))
    lines.append(
        f"{headers[0]:<{col_widths[0]}} | {headers[1]:<{col_widths[1]}} | {headers[2]:>{col_widths[2]}} | {headers[3]:<{col_widths[3]}}"
    )
    lines.append("-" * sum(col_widths))
 
    for expense in expenses:
        date = expense["date"]
        category = expense["category"]
        amount = f"${expense['amount']:.2f}"
        description = expense["description"][:27]  # Truncate long descriptions
 
        lines.append(
            f"{date:<{col_widths[0]}} | {category:<{col_widths[1]}} | {amount:>{col_widths[2]}} | {description:<{col_widths[3]}}"
        )
 
    lines.append("-" * sum(col_widths))
 
    return "\n".join(lines)
 
def main():
    """Main CLI entry point."""
    parser = argparse.ArgumentParser(
        description="Track your expenses from the command line"
    )
 
    subparsers = parser.add_subparsers(dest="command", help="Available commands")
 
    # Command: add
    add_parser = subparsers.add_parser("add", help="Add a new expense")
    add_parser.add_argument("category", help="Expense category (e.g., 'Food', 'Gas')")
    add_parser.add_argument("amount", type=float, help="Amount spent (e.g., 12.50)")
    add_parser.add_argument("description", help="Brief description of the expense")
 
    # Command: list
    list_parser = subparsers.add_parser("list", help="List all expenses")
    list_parser.add_argument("--category", help="Filter by category")
 
    # Command: summary
    summary_parser = subparsers.add_parser("summary", help="Show spending summary")
 
    # Command: total
    total_parser = subparsers.add_parser("total", help="Show total spending")
 
    # Parse arguments
    args = parser.parse_args()
 
    # Load current expenses
    expenses = load_expenses()
 
    # Handle each command
    if args.command == "add":
        try:
            expenses = add_expense(
                expenses,
                args.category,
                args.amount,
                args.description
            )
            save_expenses(expenses)
            print(f"✓ Added: {args.category} - ${args.amount:.2f}")
        except ValueError as e:
            print(f"✗ Error: {e}", file=sys.stderr)
            sys.exit(1)
 
    elif args.command == "list":
        if args.category:
            filtered = get_expenses_by_category(expenses, args.category)
            print(f"\n{args.category} Expenses:")
            print(format_expenses_table(filtered))
        else:
            print("\nAll Expenses:")
            print(format_expenses_table(expenses))
 
    elif args.command == "summary":
        totals = get_total_by_category(expenses)
        if not totals:
            print("No expenses recorded yet.")
        else:
            print("\nSpending by Category:")
            print("-" * 30)
            for category, total in sorted(totals.items()):
                print(f"{category:<20} ${total:>8.2f}")
            print("-" * 30)
 
    elif args.command == "total":
        total = get_total_spent(expenses)
        print(f"\nTotal Spending: ${total:.2f}")
 
    else:
        parser.print_help()
 
if __name__ == "__main__":
    main()

The format_expenses_table() function is doing something subtle and important: it's translating raw data into a human-readable presentation without any logic mixed in. It doesn't calculate anything, doesn't filter anything, doesn't touch the filesystem. It just takes a list and formats it as a table. Pure presentation logic, completely separate from everything else.

This is meaty, so let's walk through the key parts:

format_expenses_table() function:

  • Takes a list of expenses and formats them as a readable table
  • Uses f-string formatting with column width specifiers (< for left-align, > for right-align)
  • :.2f ensures dollar amounts always show two decimal places
  • Builds the table line-by-line and joins them with newlines

argparse setup:

  • ArgumentParser creates the CLI interface
  • add_subparsers() lets us define multiple commands: add, list, summary, total
  • Each command has its own parser with specific arguments
  • For example, the add command requires three positional arguments and a category must be provided

Command handling:

  • After parsing, we check args.command to see what the user asked for
  • add: Creates a new expense, saves it, and shows a success message (or error if validation fails)
  • list: Shows all expenses, or filters by category if --category is provided
  • summary: Groups spending by category and shows totals
  • total: Shows the grand total

Error handling:

  • If add_expense() raises a ValueError, we catch it and print to sys.stderr (the error stream)
  • sys.exit(1) tells the OS the command failed (exit code 1 means error)

Step 5: Testing Your CLI Tool

Now let's test it. From your terminal in the expense_tracker folder, we'll add several expenses across different categories to give the summary and filter commands something meaningful to work with. Watch how each command follows the same pattern: verb first, then positional arguments, then optional flags.

bash
python main.py add Food 12.50 "Lunch at cafe"

Expected output:

✓ Added: Food - $12.50

Add a few more:

bash
python main.py add Gas 45.00 "Fill-up at Shell"
python main.py add Food 8.75 "Coffee"
python main.py add Utilities 120.00 "Electric bill"

Now list all expenses:

bash
python main.py list

Expected output:

All Expenses:
----------------------------------------
Date         | Category        | Amount     | Description
----------------------------------------
2025-02-25   | Food            |     $12.50 | Lunch at cafe
2025-02-25   | Gas             |     $45.00 | Fill-up at Shell
2025-02-25   | Food            |      $8.75 | Coffee
2025-02-25   | Utilities       |    $120.00 | Electric bill
----------------------------------------

Filter by category:

bash
python main.py list --category Food

Expected output:

Food Expenses:
----------------------------------------
Date         | Category        | Amount     | Description
----------------------------------------
2025-02-25   | Food            |     $12.50 | Lunch at cafe
2025-02-25   | Food            |      $8.75 | Coffee
----------------------------------------

Show a summary:

bash
python main.py summary

Expected output:

Spending by Category:
------------------------------
Food                   $    21.25
Gas                    $    45.00
Utilities              $   120.00
------------------------------

Show the total:

bash
python main.py total

Expected output:

Total Spending: $186.25

Beautiful! Your CLI tool works end-to-end. Try running python main.py --help too, you'll see argparse automatically generated a help page from the descriptions you provided when setting up the subparsers. That's the discoverability principle in practice, and you got it for free.

What Cluster 1 Concepts Did We Use?

Let's take a moment to appreciate what you just built. You didn't just concatenate strings or calculate simple math anymore. You used every major concept from Cluster 1:

  1. Variables and Data Types: expenses lists, dictionary structures, float amounts
  2. Strings: .strip(), .lower(), .format(), f-strings with width specifiers
  3. Operators: Comparison (==), arithmetic (+, sum), membership (in)
  4. Conditionals: if/elif/else to route commands and validate input
  5. Loops: for loops to iterate expenses, list comprehensions to filter
  6. Functions: Separated logic into reusable, testable functions
  7. Error Handling: try/except for JSON corruption and ValueError for bad input
  8. Modules and stdlib: argparse for CLI, json for persistence, os for file checks, datetime for timestamps
  9. Project Structure: Separated concerns into data, logic, and CLI layers

This isn't a toy project. This is professional software engineering at a beginner level.

Extension Ideas

The expense tracker you built today is genuinely useful as-is, but it's also a perfect foundation for adding features as your Python skills grow. Here are some extensions worth exploring, roughly ordered from easiest to most ambitious.

The simplest extension is adding a delete command. You'd add a subparser for delete that takes an index number, slice the expense out of the list with expenses.pop(index), and save. Lists have a .pop() method built in, once you've read the next article, this will be a ten-minute addition. Right after that, consider adding --month filtering to the list command so you can see only February's expenses: you'd compare the first seven characters of each expense's date string to the target month.

A medium-effort extension is adding budget tracking. You could store a budget dictionary in a separate budgets.json file, then modify the summary command to show how much of each category's budget you've used. This teaches you to work with multiple data files and cross-reference them. Another medium-effort idea is adding CSV export: Python's built-in csv module makes writing a python main.py export command straightforward, and the result opens directly in Excel or Google Sheets.

The most ambitious extension is migrating from JSON to SQLite using Python's built-in sqlite3 module. This teaches you basic SQL, gives you proper querying capabilities, and makes date range filtering trivial. The migration would only touch load_expenses() and save_expenses(), everything else would stay the same, which is exactly why we built the layered architecture we did.

How to Extend This (Spoiler: Lists Are the Key)

Right now, all our expenses are stored as a flat list of dictionaries. But in Cluster 2, you'll learn about list methods and more advanced data structures. Here's what you could add:

  • Sorting: sorted() to display expenses by date or amount
  • Slicing: Show only the last 10 expenses: expenses[-10:]
  • List methods: Use .pop() to delete an expense, .insert() to add at a specific position
  • Grouping: Use zip() and sorted() to group expenses by month
  • Filtering with lambdas: Use filter() for more complex queries

For now, you have a working tool. That's the win.

Next Steps: Welcome to Cluster 2

You've completed Cluster 1. Your next article, "Python Lists: Creation, Indexing, Slicing, and Methods," will unlock even more power for this project. You'll learn how to:

  • Create lists efficiently and understand their performance characteristics
  • Slice lists with negative indices and step values
  • Use built-in list methods like .sort(), .reverse(), .extend()
  • Understand when lists are the right choice vs. other data structures

Once you master lists, adding features like "show my top 5 expenses" or "delete the last expense" becomes trivial.

Final Thoughts

You just built a real Python tool, and more importantly, you built it right. It persists data between runs. It validates input at the boundary. It handles errors gracefully without crashing. It formats output in a way that's actually readable. It has a help system that any user can discover on their own. And underneath all of that, the architecture is clean: a data layer, a logic layer, and a CLI layer that each have one job and do that job well.

This is how real software is built. Not in big monolithic functions where everything is tangled together, but in small, focused pieces with clear responsibilities that work together through well-defined interfaces. The expense tracker is beginner-level in scope but professional-level in structure, and that gap, between beginner scope and professional habits, is exactly what this series is trying to close.

When you move into data science, machine learning, and AI work, these architectural instincts will matter even more. Your data pipelines will have data layers, logic layers, and interface layers. Your ML training scripts will separate data loading from model definition from training logic. The names change, but the principle stays the same: separate your concerns, keep your functions focused, and handle your failure cases at the boundary.

Take a moment to celebrate. Then delete expenses.json and run your CLI again to test that it handles an empty state gracefully. That's the kind of attention to detail that separates amateur code from professional code. Once you've done that, crack open the next article. Lists are waiting.

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project