Build a CLI Expense Tracker

Build a Python CLI expense tracker that persists data in a local SQLite database.

Introduction

30 Second Summary

Tracking where your money goes sounds simple until you actually try to do it consistently. Spreadsheets get messy, apps want subscriptions, and most solutions are overkill for a quick "how much did I spend on food this month?" check.

In this project, you will build a command-line expense tracker in Python that stores your data in a local SQLite database. You will type commands to log expenses, filter them by category or date, and see spending summaries.

What You'll Build

A command-line expense tracker with full CRUD operations, powered by Python's standard library with zero external dependencies.

By the end of this project, you'll have:

  • A working CLI tool that lets you add, list, delete, and summarize expenses using argparse subcommands.
  • A local SQLite database that persists your expense data with dynamic filtering by category and date range.
  • A portfolio-ready GitHub repository with a professional README that anyone can clone and run instantly.
  • Secret Mission: Add monthly budget tracking with overspend warnings.

Are there any prerequisites?

You should be comfortable with basic Python concepts like functions, loops, and dictionaries. You will also need Python, a code editor (like VS Code or Cursor), and Git installed on your machine.

Set Up the Project and Database

Every expense tracker needs two things: a way to accept commands from the user, and a place to store data permanently.

In this step, you will create your project folder, initialize Git for version control, and write the database setup code that creates your SQLite database.

In this step, get ready to:
  • Verify your Python installation and create the project directory.
  • Write the database initialization function.
  • Test that the database file is created successfully.
Verify your Python installation and create the project directory

Before writing any code, you need to confirm that Python is installed and at the right version.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
  • Check your Python version by running this command:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.10 or higher

Your Python version is ready to go. Move on to creating the project folder below.

ⓧ I see an older version

You need Python 3.10 or higher for this project.

  • Visit python.org/downloads and download the latest Python installer.
  • Run the installer. On Windows, check the box that says Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check again.

ⓧ Command not found

Python is not installed on your machine yet.

  • Visit python.org/downloads and download the latest Python installer for your operating system.
  • Run the installer. On Windows, check the box that says Add python.exe to PATH before clicking Install Now.
  • Close and reopen your terminal, then run the version check again to confirm it works.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize Git by running these commands:
mkdir expense-tracker
cd expense-tracker
git init

What do these commands do?

  • mkdir expense-tracker creates a new folder called expense-tracker on your Desktop.
  • cd expense-tracker moves your terminal session into that folder.
  • git init sets up Git version control so you can track changes and push to GitHub later.
  • Create the two project files by running this command:

macOS

touch expense_tracker.py README.md

Windows

New-Item expense_tracker.py, README.md
  • Confirm the files exist by running:
ls

You should see expense_tracker.py and README.md in the output.

Don't see both files?

Make sure you ran the file creation command from inside the expense-tracker folder. Run pwd (macOS) or cd (Windows) to check your current directory.

help me troubleshoot why my files weren't created in the expense-tracker folder

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code (or Cursor if you use Cursor) and press Enter to open your code editor.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the expense-tracker folder, and click Open.

You should see expense_tracker.py and README.md in the Explorer sidebar on the left.

Set up the SQLite database initialization function

Your expense tracker needs a database to store expenses permanently. SQLite is a lightweight database that lives in a single file on your computer.

  • In the Explorer sidebar, click expense_tracker.py to open it.
  • Paste the following imports and database constant into the file:
import argparse
import sqlite3
import datetime


DATABASE = "expenses.db"

What does this code do?

  • import argparse brings in Python's built-in command-line argument parser (you will use this in the next step).
  • import sqlite3 gives you access to SQLite database operations.
  • import datetime provides date handling for expense timestamps.
  • DATABASE = "expenses.db" sets the filename for your database. Every function that needs the database will reference this constant.
  • Below the DATABASE line, add the init_db() function that creates your expenses table:
def init_db():
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS expenses (
            id INTEGER PRIMARY KEY,
            amount REAL NOT NULL,
            category TEXT NOT NULL,
            description TEXT DEFAULT '',
            date TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()

What does this function do?

This function creates the database table where all your expenses will live.

  • sqlite3.connect(DATABASE) opens (or creates) the expenses.db file.
  • CREATE TABLE IF NOT EXISTS means it only creates the table the first time. Running the script again won't break anything.
  • Each column has a type: REAL for decimal amounts, TEXT for strings, INTEGER PRIMARY KEY for a unique auto-incrementing ID.
  • conn.commit() saves the changes to disk. conn.close() releases the database connection.
  • Below init_db(), add the program entry point that calls it on startup:
def main():
    init_db()


if __name__ == "__main__":
    main()

What does this code do?

The main() function is the starting point of your program. It calls init_db() first to ensure the database always exists before any other code runs.

The if __name__ == "__main__" block means main() only runs when you execute the file directly. This is a standard Python pattern for making scripts importable without side effects.

  • Save expense_tracker.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

import argparse
import sqlite3
import datetime


DATABASE = "expenses.db"


def init_db():
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS expenses (
            id INTEGER PRIMARY KEY,
            amount REAL NOT NULL,
            category TEXT NOT NULL,
            description TEXT DEFAULT '',
            date TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()


def main():
    init_db()


if __name__ == "__main__":
    main()
Test database creation

Time to run your script and confirm it creates the database file.

  • Switch back to your terminal and run your script with this command:

macOS

python3 expense_tracker.py

Windows

python expense_tracker.py
  • Verify the database was created by running:
ls

You should see expenses.db in the output alongside expense_tracker.py and README.md.

Don't see expenses.db?

Make sure your terminal is inside the expense-tracker folder. If you closed the terminal earlier, navigate back with cd ~/Desktop/expense-tracker and try again.

If you see an error about indentation or syntax, double check that your code in expense_tracker.py matches the full code in the double-check tab above.

help me debug why my expense_tracker.py script isn't creating the database file

Your project folder is set up and your database is ready to store expenses. Next up, you will build the command-line interface that lets you interact with this database using subcommands.

Build the CLI with argparse Subcommands

Your database is ready to store expenses. Now you need a way for users to interact with it from the command line.

Professional CLI tools like git use subcommands to organize different actions (git commit, git push). Python's built-in argparse module lets you build the same pattern for your expense tracker.

In this step, get ready to:
  • Set up the argument parser with a subcommand structure.
  • Define arguments for each of the four subcommands.
  • Wire up a dispatch function to route commands.
Create the argument parser with subcommands

The argument parser is the entry point for your CLI. It reads what the user types and routes it to the right function.

  • In expense_tracker.py, find the main() function. Add the following code directly below the init_db() line:
    parser = argparse.ArgumentParser(
        prog="expense_tracker",
        description="A CLI expense tracker powered by SQLite"
    )
    subparsers = parser.add_subparsers(dest="command")

What does this code do?

ArgumentParser creates the top-level parser that reads everything the user types after python expense_tracker.py.

add_subparsers(dest="command") tells the parser to expect a subcommand as the first argument. The dest="command" stores whichever subcommand the user picks (like add or list) in a variable you can check later.

You won't see a change yet. The next chunk defines the subcommands that plug into this structure.

Define arguments for each subcommand

Each subcommand needs its own arguments. The add command requires an amount and category. The list command accepts optional filters. The delete command takes an ID. The summary command needs nothing at all.

  • Add the four subcommand definitions below the subparsers line you just wrote:
    # Add command
    add_parser = subparsers.add_parser("add", help="Add a new expense")
    add_parser.add_argument("amount", type=float, help="Expense amount")
    add_parser.add_argument("category", type=str, help="Expense category")
    add_parser.add_argument("--description", type=str, default="", help="Expense description")
    add_parser.add_argument("--date", type=str, default=None, help="Date (YYYY-MM-DD, default: today)")

    # List command
    list_parser = subparsers.add_parser("list", help="List expenses")
    list_parser.add_argument("--category", type=str, default=None, help="Filter by category")
    list_parser.add_argument("--start-date", type=str, default=None, help="Filter from date (YYYY-MM-DD)")
    list_parser.add_argument("--end-date", type=str, default=None, help="Filter to date (YYYY-MM-DD)")

    # Delete command
    delete_parser = subparsers.add_parser("delete", help="Delete an expense by ID")
    delete_parser.add_argument("id", type=int, help="Expense ID to delete")

    # Summary command
    subparsers.add_parser("summary", help="Show expense summary by category")

What does this code do?

  • add_parser("add") creates the add subcommand. Its two positional arguments (amount and category) are required. The --description and --date flags are optional.
  • add_parser("list") creates the list subcommand. All three arguments are optional filters the user can apply.
  • add_parser("delete") creates the delete subcommand with a required id argument (typed as int so argparse handles the conversion).
  • add_parser("summary") creates the summary subcommand with no additional arguments.

You won't see a change yet. The next chunk wires these subcommands to actual logic.

Wire up the parser to dispatch functions

With the subcommands defined, you need a dispatcher that reads which command the user chose and calls the right function.

  • Add the dispatch logic below the summary subcommand line:
    args = parser.parse_args()

    if args.command == "add":
        pass
    elif args.command == "list":
        pass
    elif args.command == "delete":
        pass
    elif args.command == "summary":
        pass
    else:
        parser.print_help()

What does this code do?

parser.parse_args() reads the command-line input and populates args with the values the user provided. The args.command attribute holds whichever subcommand they typed.

Each branch uses pass as a placeholder for now. You will replace these with real functions in the next steps. The else branch prints help when no subcommand is given.

  • Save expense_tracker.py.
  • Test the top-level help by running:
python expense_tracker.py --help
  • You should see a usage message listing {add,list,delete,summary} as available subcommands.
  • Test the add subcommand help by running:
python expense_tracker.py add --help
  • You should see amount and category listed as positional arguments, with --description and --date listed as options.

Not seeing the subcommands in help output?

  • Make sure the parser and subparsers code is inside main() and indented correctly (one level in from def main():).
  • Check that subparsers = parser.add_subparsers(dest="command") comes before any subparsers.add_parser() calls.
  • Confirm you saved the file before running the command.

Help me debug my argparse help output

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py before moving on.

ⓧ I'd like to double check the full code

import argparse
import sqlite3
import datetime


DATABASE = "expenses.db"


def init_db():
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS expenses (
            id INTEGER PRIMARY KEY,
            amount REAL NOT NULL,
            category TEXT NOT NULL,
            description TEXT DEFAULT '',
            date TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()


def main():
    init_db()

    parser = argparse.ArgumentParser(
        prog="expense_tracker",
        description="A CLI expense tracker powered by SQLite"
    )
    subparsers = parser.add_subparsers(dest="command")

    # Add command
    add_parser = subparsers.add_parser("add", help="Add a new expense")
    add_parser.add_argument("amount", type=float, help="Expense amount")
    add_parser.add_argument("category", type=str, help="Expense category")
    add_parser.add_argument("--description", type=str, default="", help="Expense description")
    add_parser.add_argument("--date", type=str, default=None, help="Date (YYYY-MM-DD, default: today)")

    # List command
    list_parser = subparsers.add_parser("list", help="List expenses")
    list_parser.add_argument("--category", type=str, default=None, help="Filter by category")
    list_parser.add_argument("--start-date", type=str, default=None, help="Filter from date (YYYY-MM-DD)")
    list_parser.add_argument("--end-date", type=str, default=None, help="Filter to date (YYYY-MM-DD)")

    # Delete command
    delete_parser = subparsers.add_parser("delete", help="Delete an expense by ID")
    delete_parser.add_argument("id", type=int, help="Expense ID to delete")

    # Summary command
    subparsers.add_parser("summary", help="Show expense summary by category")

    args = parser.parse_args()

    if args.command == "add":
        pass
    elif args.command == "list":
        pass
    elif args.command == "delete":
        pass
    elif args.command == "summary":
        pass
    else:
        parser.print_help()


if __name__ == "__main__":
    main()

Your CLI skeleton is complete. Next up, you will implement the add and list functions so your tracker can actually store and retrieve expenses.

Implement Add and List Operations

Your CLI structure is in place, but every command just hits pass and does nothing. Time to make the tracker actually track expenses.

In this step, you will write two core functions: one to insert expenses into the database, and one to retrieve them with optional filters. These two operations form the foundation of every data tool.

In this step, get ready to:
  • Implement the add_expense function to insert expenses into SQLite.
  • Implement the list_expenses function with dynamic filtering.
  • Test both commands together to confirm expenses persist and filter correctly.
Implement the add_expense function

The add command needs a function that takes the parsed arguments, opens a database connection, inserts a row, and confirms the result. It also needs a sensible default for the date when the user does not provide one.

  • In expense_tracker.py, find the closing line of your init_db() function. Add the following add_expense function below it, above def main()::
def add_expense(amount, category, description, date):
    # Default to today's date if none provided
    if date is None:
        date = datetime.date.today().isoformat()
    # Connect and insert the expense using parameterized query
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute(
        "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
        (amount, category, description, date)
    )
    # Save and close
    conn.commit()
    conn.close()
    # Confirm what was added
    print(f"Expense added: ${amount:.2f} | {category} | {description} | {date}")

What does this code do?

This function handles the full lifecycle of adding one expense to the database.

  • if date is None fills in today's date automatically using datetime.date.today().isoformat(), which produces a string like 2026-06-16.
  • The ? placeholders in the SQL are parameterized queries. They prevent SQL injection by letting SQLite handle value escaping instead of string formatting.
  • conn.commit() saves the change to disk. Without it, the insert would be rolled back when the connection closes.
  • Save expense_tracker.py.

You will not see any output yet. The function exists but is not called from main(). We will wire it up after building the list function.

Seeing a SyntaxError?

  • Make sure the function is placed between init_db() and def main(): with a blank line separating each function.
  • Check that the f-string uses curly braces {amount:.2f} with a colon inside, not a period.

Help me debug my add_expense function

Implement the list_expenses function with filtering

Listing expenses is more involved because users can optionally filter by category and date range. The function builds a SQL query dynamically, appending filter clauses only when the user provides them.

  • Below your add_expense function and above def main():, add the following list_expenses function:
def list_expenses(category, start_date, end_date):
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    # Start with a base query that always matches
    query = "SELECT id, amount, category, description, date FROM expenses WHERE 1=1"
    params = []

    # Append optional filter clauses
    if category:
        query += " AND category = ?"
        params.append(category)
    if start_date:
        query += " AND date >= ?"
        params.append(start_date)
    if end_date:
        query += " AND date <= ?"
        params.append(end_date)

    query += " ORDER BY date DESC"
    cursor.execute(query, params)
    rows = cursor.fetchall()
    conn.close()

    # Handle empty results
    if not rows:
        print("No expenses found.")
        return

    # Print a formatted table with aligned columns
    print(f"{'ID':<5} {'Amount':<10} {'Category':<12} {'Description':<20} {'Date':<12}")
    print("-" * 59)
    for row in rows:
        expense_id, amount, cat, desc, date = row
        print(f"{expense_id:<5} ${amount:<9.2f} {cat:<12} {desc:<20} {date:<12}")

    # Print total at the bottom
    total = sum(row[1] for row in rows)
    print("-" * 59)
    print(f"Total: ${total:.2f} ({len(rows)} expenses)")

What does this code do?

  • WHERE 1=1 is a common pattern for building dynamic queries. It always evaluates to true, so every additional filter can safely start with AND without worrying about whether it is the first clause.
  • Each filter clause appends both the SQL fragment and its value to the params list. This keeps the query parameterized and safe from injection.
  • Date comparisons like date >= ? work because dates stored in YYYY-MM-DD format sort correctly as plain text.
  • The f"{...:<5}" syntax left-aligns text inside a fixed-width column, giving the output a clean table appearance.
  • Save expense_tracker.py.

You will not see output yet. The next substep connects both functions to the CLI and runs test commands.

Seeing an IndentationError?

  • Python is strict about indentation. Make sure the entire function body uses 4 spaces per level, not a mix of tabs and spaces.
  • The for row in rows: loop should be indented one level inside the function, and the print inside the loop should be indented one further level.

Help me fix my list_expenses function

Test add and list together

Both functions exist but the main() dispatch still says pass for each command. You need to replace those placeholder lines with actual function calls.

  • In expense_tracker.py, scroll down to the if args.command block at the bottom of main().
  • Find these two lines:
    if args.command == "add":
        pass
    elif args.command == "list":
        pass
  • Replace both pass statements with function calls, so the block looks like this:
    if args.command == "add":
        add_expense(args.amount, args.category, args.description, args.date)
    elif args.command == "list":
        list_expenses(args.category, args.start_date, args.end_date)

What's happening here?

The args object holds everything the user typed on the command line. argparse already validated types and filled in defaults, so your functions receive clean, ready-to-use values.

  • Save expense_tracker.py.
  • Add a test expense by running this command:
python expense_tracker.py add 15.50 Food --description "Coffee and sandwich"
  • You should see a confirmation like:
Expense added: $15.50 | Food | Coffee and sandwich | 2026-06-16
  • Add a second expense in a different category by running:
python expense_tracker.py add 45.00 Transport --description "Monthly bus pass"
  • List all expenses by running:
python expense_tracker.py list

You should see a formatted table with both expenses, a separator line, and a total at the bottom.

  • Test the category filter by running:
python expense_tracker.py list --category Food

Only the Food expense should appear. The filter narrows results to a single category.

Seeing 'No expenses found' after adding?

  • Confirm that conn.commit() is called in add_expense before conn.close(). Without the commit, inserts are never saved.
  • Make sure the DATABASE variable at the top of the file matches in both functions. Both should reference "expenses.db".
  • Check that --category values are case-sensitive. If you added "Food" but filter by "food", it will not match.

Help me debug why my expenses aren't showing up

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py and your test commands produce the expected output.

ⓧ I'd like to double check the full code

Here is the full expense_tracker.py as it should look at the end of this step:

import argparse
import sqlite3
import datetime


DATABASE = "expenses.db"


def init_db():
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS expenses (
            id INTEGER PRIMARY KEY,
            amount REAL NOT NULL,
            category TEXT NOT NULL,
            description TEXT DEFAULT '',
            date TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()


def add_expense(amount, category, description, date):
    if date is None:
        date = datetime.date.today().isoformat()
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute(
        "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
        (amount, category, description, date)
    )
    conn.commit()
    conn.close()
    print(f"Expense added: ${amount:.2f} | {category} | {description} | {date}")


def list_expenses(category, start_date, end_date):
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    query = "SELECT id, amount, category, description, date FROM expenses WHERE 1=1"
    params = []

    if category:
        query += " AND category = ?"
        params.append(category)
    if start_date:
        query += " AND date >= ?"
        params.append(start_date)
    if end_date:
        query += " AND date <= ?"
        params.append(end_date)

    query += " ORDER BY date DESC"
    cursor.execute(query, params)
    rows = cursor.fetchall()
    conn.close()

    if not rows:
        print("No expenses found.")
        return

    print(f"{'ID':<5} {'Amount':<10} {'Category':<12} {'Description':<20} {'Date':<12}")
    print("-" * 59)
    for row in rows:
        expense_id, amount, cat, desc, date = row
        print(f"{expense_id:<5} ${amount:<9.2f} {cat:<12} {desc:<20} {date:<12}")

    total = sum(row[1] for row in rows)
    print("-" * 59)
    print(f"Total: ${total:.2f} ({len(rows)} expenses)")


def main():
    init_db()

    parser = argparse.ArgumentParser(
        prog="expense_tracker",
        description="A CLI expense tracker powered by SQLite"
    )
    subparsers = parser.add_subparsers(dest="command")

    # Add command
    add_parser = subparsers.add_parser("add", help="Add a new expense")
    add_parser.add_argument("amount", type=float, help="Expense amount")
    add_parser.add_argument("category", type=str, help="Expense category")
    add_parser.add_argument("--description", type=str, default="", help="Expense description")
    add_parser.add_argument("--date", type=str, default=None, help="Date (YYYY-MM-DD, default: today)")

    # List command
    list_parser = subparsers.add_parser("list", help="List expenses")
    list_parser.add_argument("--category", type=str, default=None, help="Filter by category")
    list_parser.add_argument("--start-date", type=str, default=None, help="Filter from date (YYYY-MM-DD)")
    list_parser.add_argument("--end-date", type=str, default=None, help="Filter to date (YYYY-MM-DD)")

    # Delete command
    delete_parser = subparsers.add_parser("delete", help="Delete an expense by ID")
    delete_parser.add_argument("id", type=int, help="Expense ID to delete")

    # Summary command
    subparsers.add_parser("summary", help="Show expense summary by category")

    args = parser.parse_args()

    if args.command == "add":
        add_expense(args.amount, args.category, args.description, args.date)
    elif args.command == "list":
        list_expenses(args.category, args.start_date, args.end_date)
    elif args.command == "delete":
        pass
    elif args.command == "summary":
        pass
    else:
        parser.print_help()


if __name__ == "__main__":
    main()

Your tracker can now store and retrieve expenses with filtering. Next up, you will add the ability to delete expenses and view a spending summary by category.

Implement Delete and Summary Operations

Your tracker can add and list expenses. Now you need a way to remove mistakes and see spending patterns at a glance.

In this step you will write two more functions that complete your CRUD operations. The delete function removes a single expense by ID, and the summary function uses SQL aggregation to show spending grouped by category.

In this step, get ready to:
  • Implement a delete function that validates whether the expense exists.
  • Implement a summary function that aggregates spending by category.
  • Test all four commands working together end-to-end.
Implement the delete_expense function

When users log an expense by mistake, they need a way to remove it. Your delete function takes an expense ID and removes the matching row from the database.

  • In expense_tracker.py, find the end of the list_expenses function.
  • Add the following delete_expense function below it:
def delete_expense(expense_id):
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
    conn.commit()
    conn.close()

    if cursor.rowcount > 0:
        print(f"Expense #{expense_id} deleted.")
    else:
        print(f"Expense #{expense_id} not found.")

What does this code do?

  • DELETE FROM expenses WHERE id = ? removes the row matching the given ID. The ? placeholder prevents SQL injection.
  • cursor.rowcount tells you how many rows the DELETE statement modified. If it is 0, no expense with that ID existed.
  • rowcount is set on the cursor after execute() completes, so it stays available even after you close the connection.

Now wire the delete command to your new function. The parser already recognizes the delete subcommand from the previous step, but it currently does nothing.

  • In the main() function, find this line:
    elif args.command == "delete":
        pass
  • Replace pass with delete_expense(args.id), so the line now looks like this:
    elif args.command == "delete":
        delete_expense(args.id)
  • Save expense_tracker.py.
  • Test by running the delete command with an ID from your earlier test expenses:
python expense_tracker.py delete 1
  • You should see Expense #1 deleted. printed in your terminal.
  • Try deleting the same ID again to confirm you see the "not found" message:
python expense_tracker.py delete 1
  • You should see Expense #1 not found. this time.

Seeing a NameError?

  • Make sure your delete_expense function is defined above the main() function, not inside it.
  • Check that the function name matches exactly: delete_expense in both the definition and the call.

Why is my delete command not working?

Implement the summary_expenses function

A spending summary helps users spot where their money goes. SQL's GROUP BY clause does the heavy lifting, grouping expenses by category and totalling each group in a single query.

  • In expense_tracker.py, find the end of the delete_expense function you just added.
  • Add the following summary_expenses function below it:
def summary_expenses():
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute(
        "SELECT category, SUM(amount) as total FROM expenses GROUP BY category ORDER BY total DESC"
    )
    rows = cursor.fetchall()
    conn.close()

    if not rows:
        print("No expenses recorded yet.")
        return

    print("Expense Summary by Category")
    print("=" * 35)
    grand_total = 0
    for category, total in rows:
        print(f"  {category:<15} ${total:.2f}")
        grand_total += total
    print("=" * 35)
    print(f"  {'TOTAL':<15} ${grand_total:.2f}")

What does this code do?

  • SELECT category, SUM(amount) as total FROM expenses GROUP BY category groups all expenses by their category and sums the amounts within each group.
  • ORDER BY total DESC puts the highest-spending category first.
  • The loop accumulates a grand_total across all categories so you can display total spending at the bottom.

Now wire the summary command to your function, just like you did for delete.

  • In the main() function, find this line:
    elif args.command == "summary":
        pass
  • Replace pass with summary_expenses(), so the line now looks like this:
    elif args.command == "summary":
        summary_expenses()
  • Save expense_tracker.py.

Seeing a syntax error?

  • Check that your summary_expenses function is at the same indentation level as your other functions (no extra indent).
  • Make sure the f-string quotes are consistent. The format strings use double quotes on the outside and single quotes are not needed inside the f"..." expressions.

Why is my summary function raising an error?

Test the complete workflow

All four commands are now connected. Time to run them together and confirm the full cycle works.

  • Add a few test expenses across different categories by running these commands:
python expense_tracker.py add 12.50 Food --description "Coffee and bagel"
python expense_tracker.py add 45.00 Transport --description "Monthly bus pass"
python expense_tracker.py add 9.99 Food --description "Snacks"
python expense_tracker.py add 60.00 Utilities --description "Internet bill"
  • You should see a confirmation message for each expense added.
  • Run the summary command to see spending grouped by category:
python expense_tracker.py summary
  • You should see a category breakdown with Utilities, Food, and Transport listed with their totals, plus a grand total at the bottom.
  • List all expenses to pick an ID to delete:
python expense_tracker.py list
  • Note the ID of one of the expenses in the table.
  • Delete that expense by running (replace 2 with the ID you chose):
python expense_tracker.py delete 2
  • Run python expense_tracker.py list again to confirm the deleted expense no longer appears.

What should I see?

The list output should now show one fewer expense. The summary totals should also reflect the deletion since both commands query the same database.

✔️ Awesome, I've got everything!

Great. Double check you have saved expense_tracker.py and all four commands are working.

ⓧ I'd like to double check the full code

Here is the complete expense_tracker.py at this point:

import argparse
import sqlite3
import datetime


DATABASE = "expenses.db"


def init_db():
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute("""
        CREATE TABLE IF NOT EXISTS expenses (
            id INTEGER PRIMARY KEY,
            amount REAL NOT NULL,
            category TEXT NOT NULL,
            description TEXT DEFAULT '',
            date TEXT NOT NULL
        )
    """)
    conn.commit()
    conn.close()


def add_expense(amount, category, description, date):
    if date is None:
        date = datetime.date.today().isoformat()
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute(
        "INSERT INTO expenses (amount, category, description, date) VALUES (?, ?, ?, ?)",
        (amount, category, description, date)
    )
    conn.commit()
    conn.close()
    print(f"Expense added: ${amount:.2f} | {category} | {description} | {date}")


def list_expenses(category, start_date, end_date):
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    query = "SELECT id, amount, category, description, date FROM expenses WHERE 1=1"
    params = []

    if category:
        query += " AND category = ?"
        params.append(category)
    if start_date:
        query += " AND date >= ?"
        params.append(start_date)
    if end_date:
        query += " AND date <= ?"
        params.append(end_date)

    query += " ORDER BY date DESC"
    cursor.execute(query, params)
    rows = cursor.fetchall()
    conn.close()

    if not rows:
        print("No expenses found.")
        return

    print(f"{'ID':<5} {'Amount':<10} {'Category':<12} {'Description':<20} {'Date':<12}")
    print("-" * 59)
    for row in rows:
        expense_id, amount, cat, desc, date = row
        print(f"{expense_id:<5} ${amount:<9.2f} {cat:<12} {desc:<20} {date:<12}")

    total = sum(row[1] for row in rows)
    print("-" * 59)
    print(f"Total: ${total:.2f} ({len(rows)} expenses)")


def delete_expense(expense_id):
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute("DELETE FROM expenses WHERE id = ?", (expense_id,))
    conn.commit()
    conn.close()

    if cursor.rowcount > 0:
        print(f"Expense #{expense_id} deleted.")
    else:
        print(f"Expense #{expense_id} not found.")


def summary_expenses():
    conn = sqlite3.connect(DATABASE)
    cursor = conn.cursor()
    cursor.execute(
        "SELECT category, SUM(amount) as total FROM expenses GROUP BY category ORDER BY total DESC"
    )
    rows = cursor.fetchall()
    conn.close()

    if not rows:
        print("No expenses recorded yet.")
        return

    print("Expense Summary by Category")
    print("=" * 35)
    grand_total = 0
    for category, total in rows:
        print(f"  {category:<15} ${total:.2f}")
        grand_total += total
    print("=" * 35)
    print(f"  {'TOTAL':<15} ${grand_total:.2f}")


def main():
    init_db()

    parser = argparse.ArgumentParser(
        prog="expense_tracker",
        description="A CLI expense tracker powered by SQLite"
    )
    subparsers = parser.add_subparsers(dest="command")

    # Add command
    add_parser = subparsers.add_parser("add", help="Add a new expense")
    add_parser.add_argument("amount", type=float, help="Expense amount")
    add_parser.add_argument("category", type=str, help="Expense category")
    add_parser.add_argument("--description", type=str, default="", help="Expense description")
    add_parser.add_argument("--date", type=str, default=None, help="Date (YYYY-MM-DD, default: today)")

    # List command
    list_parser = subparsers.add_parser("list", help="List expenses")
    list_parser.add_argument("--category", type=str, default=None, help="Filter by category")
    list_parser.add_argument("--start-date", type=str, default=None, help="Filter from date (YYYY-MM-DD)")
    list_parser.add_argument("--end-date", type=str, default=None, help="Filter to date (YYYY-MM-DD)")

    # Delete command
    delete_parser = subparsers.add_parser("delete", help="Delete an expense by ID")
    delete_parser.add_argument("id", type=int, help="Expense ID to delete")

    # Summary command
    subparsers.add_parser("summary", help="Show expense summary by category")

    args = parser.parse_args()

    if args.command == "add":
        add_expense(args.amount, args.category, args.description, args.date)
    elif args.command == "list":
        list_expenses(args.category, args.start_date, args.end_date)
    elif args.command == "delete":
        delete_expense(args.id)
    elif args.command == "summary":
        summary_expenses()
    else:
        parser.print_help()


if __name__ == "__main__":
    main()

Your expense tracker is fully functional. All four CRUD operations work and you have a real database backing your data. Next up, you will push this project to GitHub with a professional README so anyone can find and use your tool.

Push to GitHub

Your expense tracker is feature-complete. All four commands work end-to-end with real data persistence.

Now it's time to share your work. You'll push the project to GitHub so anyone can clone and run it instantly. A clean README is what turns a personal script into a professional portfolio piece.

In this step, get ready to:
  • Write a professional README.md with usage examples.
  • Create a .gitignore and commit your project.
  • Push to GitHub and verify a fresh clone works.
Write a professional README

A README is the first thing people see when they visit your repository. It tells them what the project does, how to install it, and how to use it.

  • Open README.md in your code editor and paste the following content:
# Expense Tracker CLI

A command-line expense tracker powered by SQLite — zero external dependencies.

## Features
- Add expenses with category, description, and date
- List and filter expenses by category or date range
- Delete expenses by ID
- View spending summary by category

## Installation
```
git clone <your-repo-url>
cd expense-tracker
python expense_tracker.py --help
```

## Usage
```
python expense_tracker.py add 25.50 Food --description "Lunch"
python expense_tracker.py list --category Food
python expense_tracker.py list --start-date 2024-01-01 --end-date 2024-01-31
python expense_tracker.py delete 1
python expense_tracker.py summary
```

## Requirements
Python 3.10+ — no external packages needed.

What does this README include?

  • A title and one-line description so visitors instantly understand the project.
  • A features list that highlights what the tool can do.
  • Installation instructions showing how to clone and run the tool with zero setup.
  • Usage examples for every command so people can copy-paste and try it immediately.
  • A requirements note making it clear there are no external dependencies to install.
  • Save README.md.

Replace the repo URL later

The <your-repo-url> placeholder in the Installation section will be replaced with your actual GitHub URL after you push the repository.

✔️ Awesome, I've got everything!

Great. Make sure you've saved README.md before moving on.

ⓧ I'd like to double check the full code

# Expense Tracker CLI

A command-line expense tracker powered by SQLite — zero external dependencies.

## Features
- Add expenses with category, description, and date
- List and filter expenses by category or date range
- Delete expenses by ID
- View spending summary by category

## Installation
```
git clone <your-repo-url>
cd expense-tracker
python expense_tracker.py --help
```

## Usage
```
python expense_tracker.py add 25.50 Food --description "Lunch"
python expense_tracker.py list --category Food
python expense_tracker.py list --start-date 2024-01-01 --end-date 2024-01-31
python expense_tracker.py delete 1
python expense_tracker.py summary
```

## Requirements
Python 3.10+ — no external packages needed.
Create a .gitignore and commit your project

Your expenses.db file contains personal data, not source code. A .gitignore file tells Git which files to leave out of your repository.

  • Create a new file called .gitignore in your expense-tracker folder and add this content:
expenses.db
__pycache__/

Why ignore these files?

The expenses.db database is user-specific data that each person generates locally. Committing it would push your personal expenses to a public repo.

The __pycache__/ folder contains compiled bytecode that Python auto-generates. It's machine-specific and should never be tracked.

  • Save .gitignore.
  • Stage all your project files and create your first commit by running these commands:
git add .
git commit -m "Initial commit: CLI expense tracker with SQLite"

What do these commands do?

  • git add . stages all files in the current directory (except those listed in .gitignore).
  • git commit -m saves the staged files as a snapshot with a descriptive message.

You should see output listing the files being committed, including expense_tracker.py, README.md, and .gitignore.

Now create a new repository on GitHub to push your code to.

  • Open your browser and go to github.com/new.
  • Enter expense-tracker as the Repository name.
  • Select Public for visibility.
  • Leave all the "Initialize this repository with" checkboxes unchecked. You already have files locally.
  • Click Create repository.

Why leave the checkboxes unchecked?

If you initialize with a README or .gitignore on GitHub, it creates a commit that conflicts with your local commit. Since you already have these files locally, you want an empty repository to push into.

  • Copy the repository URL from the page that appears (it looks like https://github.com/your-username/expense-tracker.git).
  • Connect your local repo to GitHub and push by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/expense-tracker.git"]]
git branch -M main
git push -u origin main

What do these commands do?

  • git remote add origin links your local repository to the GitHub repository.
  • git branch -M main renames your current branch to main (the GitHub default).
  • git push -u origin main uploads your commit to GitHub and sets main as the default push target.

Push rejected or authentication error?

  • If you see a "permission denied" or authentication error, make sure you're signed in to Git. GitHub requires a personal access token or SSH key for pushes.
  • If the push is rejected because the remote contains work you don't have locally, you likely checked one of the initialization boxes on GitHub. Delete the GitHub repo, recreate it with no initialization, and try again.

Help me fix a git push error to GitHub.

Verify the repository
  • Visit your GitHub repository in your browser (the URL is github.com/your-username/expense-tracker).
  • Confirm the README renders with a title, features list, and usage examples.
  • Confirm that expenses.db does NOT appear in the file list (your .gitignore is working).

Now verify the tool works from a fresh clone.

  • Move to a temporary directory and clone the repository by running these commands:
cd ~/Desktop
git clone [[YOUR_REPO_URL="https://github.com/your-username/expense-tracker.git"]] expense-tracker-test
cd expense-tracker-test
python expense_tracker.py --help
  • Confirm that the help output displays all four subcommands: add, list, delete, and summary.

Why clone into a fresh directory?

Cloning into a separate folder proves that everything needed to run the project is actually in the repository. If you forgot to commit a file, this test will catch it.

  • Clean up the test clone when you're done by running:
cd ~/Desktop
rm -rf expense-tracker-test

README not rendering on GitHub?

  • Make sure the file is named exactly README.md (case-sensitive). GitHub only auto-renders the README if the filename matches.
  • If you see raw markdown, check that the file was committed. Run git status to confirm there are no uncommitted changes.

Help me troubleshoot my GitHub README not displaying correctly.

Secret mission

Add Monthly Budget Tracking

Your expense tracker can record and summarize spending, but it has no guardrails. What if you could set monthly budgets for each category and get warnings when you overspend? In this secret mission, you will add a budget subcommand that stores limits in a JSON file and flags any category that goes over.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources available, leave them idle, or delete them entirely.

Resources you used:

  • expense-tracker project folder on your Desktop (contains expense_tracker.py, README.md, .gitignore, expenses.db, budgets.json).
  • GitHub repository hosting your expense tracker code.

Keep everything running

No action needed. Choose this if you plan to keep using the expense tracker or want to extend it further.

  • Your project folder stays on your Desktop, ready to use anytime.
  • Your GitHub repository remains public for portfolio visitors.
  • Your expenses.db database retains all your test expense data.

Pause - I'll come back to this later

Since this is a local CLI tool with no running processes, there is nothing to pause. The tool only runs when you invoke it.

  • Leave everything as-is. Nothing consumes memory or CPU when you are not actively running the script.
  • If you want a clean slate for expense data later, delete expenses.db and budgets.json from the expense-tracker folder. The script recreates the database on the next run.

Delete - I don't want to use this again

Remove all project files from your machine and optionally delete the GitHub repository.

  • Delete the local project folder by running this command in your terminal:

macOS

rm -rf ~/Desktop/expense-tracker

Windows

rmdir /s /q %USERPROFILE%\Desktop\expense-tracker
  • To delete your GitHub repository, go to your repository page on GitHub.
  • Click Settings in the top navigation bar.
  • Scroll to the bottom of the page to the Danger Zone section.
  • Click Delete this repository and follow the confirmation prompts.

Nice Work!

Nice Work!

You just built a fully functional CLI expense tracker using nothing but Python's standard library. Anyone can clone your repo and start tracking expenses immediately with zero setup.

Here's what you accomplished:

  • Built a complete CLI expense tracker with full CRUD operations using only argparse and sqlite3 from Python's standard library.
  • Implemented parameterized SQL queries for safe database operations with dynamic filtering by category and date range.
  • Designed a professional subcommand interface with positional arguments, optional flags, and helpful error messages, then pushed it to a GitHub repository with a clean README.
  • Secret Mission: Added monthly budget tracking with overspend warnings that compare current month spending against configurable category budgets.

Ready to quiz yourself?