Build a real project.
Stop learning. Start shipping.
You've covered every beginner concept. This is the project that turns knowledge into skill. You'll build an expense tracker — a command-line tool that records, lists, and summarizes expenses, saved to a JSON file. It uses functions, files, dicts, loops, error handling, and modules — all at once, exactly the way real code does.
This project is a command-line expense tracker. You run python expenses.py add 12.50 "Coffee" to log an expense, python expenses.py list to see them all, and python expenses.py summary to see totals. Data persists in expenses.json. The code uses argparse for the CLI, json for persistence, and functions for clean structure.
Before this project: complete Lessons 1–8. You need to be comfortable with functions, dicts, files, errors, and modules. This project uses all five.
📋 Plan first, code second
The five-minute plan that saves an hour of refactoring.
Before writing any code, answer three questions. Every project — from a CLI tool to a startup — starts with these:
1. What does it do?
Record an expense (amount, description, date). List all expenses. Show a summary (total, count, breakdown by month). That's three commands.
2. Where does data live?
A JSON file called expenses.json in the current directory. Read on startup, save on every change. Small enough that JSON is fine.
3. How is it structured?
One file for simplicity, split into functions: load(), save(), add_expense(), list_expenses(), summary(), plus a main() that parses args.
Write the CLI help first. Before writing any logic, sketch what the user will type:
python expenses.py list $` python expenses.py summary
If the commands feel natural, the code will too. If they feel awkward, redesign now — not after 200 lines of code.
💻 The full code
Save as expenses.py and run it. Then read the walkthrough below.
#!/usr/bin/env python3
"""expenses.py — a command-line expense tracker."""
import argparse
import json
from datetime import date
from pathlib import Path
FILE = Path("expenses.json")
# ---------- persistence ----------
def load():
"""Load expenses from JSON. Return empty list if file missing."""
if not FILE.exists():
return []
try:
with FILE.open() as f:
return json.load(f)
except json.JSONDecodeError:
print(f"Warning: {FILE} is corrupt. Starting fresh.")
return []
def save(expenses):
"""Write expenses to JSON."""
with FILE.open("w") as f:
json.dump(expenses, f, indent=2)
# ---------- commands ----------
def add_expense(amount, description, category="general"):
"""Add an expense and save."""
expenses = load()
expenses.append({
"date": date.today().isoformat(),
"amount": amount,
"description": description,
"category": category,
})
save(expenses)
print(f"Added: `${amount:.2f} — {description} ({category})")
def list_expenses():
"""Print all expenses."""
expenses = load()
if not expenses:
print("No expenses yet. Use 'add' to create one.")
return
print(f"{'Date':12} {'Amount':>10} {'Category':12} Description")
print("-" * 70)
for e in expenses:
print(f"{e['date']:12} $`{e['amount']:>9.2f} {e['category']:12} {e['description']}")
def summary():
"""Print total, count, and per-category breakdown."""
expenses = load()
if not expenses:
print("No expenses to summarize.")
return
total = sum(e["amount"] for e in expenses)
print(f"Total spent: `${total:.2f} across {len(expenses)} expenses")
print()
by_category = {}
for e in expenses:
cat = e["category"]
by_category[cat] = by_category.get(cat, 0) + e["amount"]
print("By category:")
for cat, amt in sorted(by_category.items(), key=lambda kv: kv[1], reverse=True):
pct = (amt / total) * 100
print(f" {cat:15} $`{amt:>8.2f} ({pct:.0f}%)")
# ---------- CLI ----------
def main():
parser = argparse.ArgumentParser(description="Track your expenses from the command line.")
sub = parser.add_subparsers(dest="command", required=True)
add = sub.add_parser("add", help="Add an expense")
add.add_argument("amount", type=float, help="Amount in dollars")
add.add_argument("description", help="Description")
add.add_argument("--category", default="general", help="Category (default: general)")
sub.add_parser("list", help="List all expenses")
sub.add_parser("summary", help="Show summary")
args = parser.parse_args()
if args.command == "add":
add_expense(args.amount, args.description, args.category)
elif args.command == "list":
list_expenses()
elif args.command == "summary":
summary()
if name == "main":
main()
🔍 Walkthrough — why each part exists
The code is short. The decisions behind it are what you're learning.
The FILE constant
FILE = Path("expenses.json") at the top means the filename appears exactly once. If you later want to change it to ~/.expenses.json, you edit one line. Constants at the top of a file are a habit worth building — they turn "magic values scattered through code" into "one place to change."
load() and save() are the only functions that touch the file
Every other function calls these. That means if you later switch from JSON to SQLite, or add encryption, you change two functions. This is called separating I/O from logic — a pattern every serious application follows.
load() handles missing and corrupt files
Three cases: file exists and is valid → load it. File doesn't exist → return []. File exists but is corrupt JSON → print a warning and return []. All three are handled in one function, and every caller gets a valid list. That's defensive programming — the rest of the code never has to wonder if the file is broken.
add_expense() loads, modifies, saves
The three-step pattern from Lesson 6: read everything, change the in-memory list, write everything back. For a personal tracker, this is instantaneous and safe. If two processes run add at the same instant, one loses — but that's not a real concern for a CLI tool. A database solves concurrency; a JSON file doesn't need to.
list_expenses() uses format specifiers
{e['amount']:>9.2f} right-aligns the amount in a 9-character field with 2 decimal places. This is how CLI tables stay readable — the columns line up regardless of whether a value is $3.50</code> or <code>$1250.00. Format specifiers are worth learning early; you'll use them every time you print a table.
summary() uses the aggregation patterns from Lessons 5 and 7
sum(e["amount"] for e in expenses) is a generator expression — memory-efficient, readable. by_category.get(cat, 0) + e["amount"] is the accumulation pattern from Lesson 5. sorted(items, key=lambda kv: kv[1], reverse=True) sorts by value, descending. Three patterns from prior lessons, combined into one function — this is what "using what you've learned" looks like.
argparse handles the CLI for you
add_subparsers() creates the add / list / summary commands. required=True means the user must specify one. type=float automatically converts the string to a float and errors if it can't. Every argument gets a --help line generated for free. You write 10 lines of argparse; you get a real CLI with help text, subcommands, and argument validation.
The if name == "main" guard
This line means "only run main() if this file is executed directly, not if it's imported as a module." Without it, anyone who imports expenses.py would trigger the CLI. With it, the file is both a script and a library. Every Python file that's meant to be both should have this guard.
🧪 Try it out
Five commands that exercise every part of the tool.
# Add three expenses
python expenses.py add 12.50 "Coffee" --category food
python expenses.py add 45.00 "Gas" --category transport
python expenses.py add 8.99 "Streaming" --category entertainment
# List them
python expenses.py list
Date Amount Category Description
----------------------------------------------------------------------
2026-10-23 $ 12.50 food Coffee</span> <span class="out">2026-10-23 $ 45.00 transport Gas
2026-10-23 `$ 8.99 entertainment Streaming
# Summarize
python expenses.py summary
Total spent: 45.00 (68%)
food 8.99 (14%)
# See the auto-generated help
python expenses.py --help
python expenses.py add --help
🚀 Extend it — five upgrades, in order
Each one is a mini-project. Do them in order; they build on each other.
1. Delete command
Add delete that removes an expense by index. Requires loading, removing by position, saving. Teaches you list mutation and CLI argument design.
2. Date filtering
Add --since YYYY-MM-DD to the list and summary commands. Parse with datetime.strptime, filter with a loop. Teaches argument types and date parsing.
3. Monthly summary
Group expenses by month (e["date"][:7] gives "2026-10") and print per-month totals. Uses the same aggregation pattern as category grouping.
4. CSV export
Add export that writes to CSV. Import csv, use csv.DictWriter. Teaches file format conversion.
5. Budget alerts
Add budget set 500 and warn when monthly spending exceeds it. Requires a second data file (config.json) and reading two files. Teaches config management.
6. Tests with pytest
Install pytest. Write tests for add_expense, summary, and the date filtering. Teaches you why pure functions matter — they're trivial to test.
Do at least one upgrade before moving on. Reading this project teaches you what's possible. Extending it teaches you what you actually know. Pick the upgrade that excites you most and build it end-to-end — you'll hit bugs you didn't expect, and fixing them is the real education. That's why real developers say "I learned more from my own projects than from any tutorial."
🏆 Final challenges
Three things to prove you've graduated from this track.
Challenge 1 — Ship it
Make the tool runnable from anywhere on your machine:
1. Add #!/usr/bin/env python3 as the first line (already there).
2. Run chmod +x expenses.py (Mac/Linux).
3. Move it to ~/bin/ or add the folder to your PATH.
4. Run expenses add 5 "Test" from any directory.
You now have a real CLI tool installed. Not a script — a tool.
Challenge 2 — Put it on GitHub
Create a repo, add the file, write a README that explains:
1. What the tool does (one paragraph).
2. How to install it (three commands).
3. How to use it (examples of each command).
4. What you might add next (the "extend" ideas).
Add expenses.json and venv/ and pycache/ to .gitignore. Commit. Push.
This is your first real Python project on your GitHub. From this point on, when someone asks "what have you built?", you have an answer.
Challenge 3 — Build the next thing
Choose one project from the list below. Each one uses the same structure (argparse, JSON/SQLite, functions) but for a different problem:
• A book tracker (add, list, rate, stats)
• A password generator (generate --length 20)
• A markdown-to-HTML converter (convert file.md)
• A file organizer (sort downloads/ — moves files into folders by extension)
• A habit tracker (check running, streaks)
Pick the one you'd actually use. Build it in a weekend. Put it on GitHub. Repeat.
🎓 You're done with the beginner + early-intermediate track
Nine lessons. One real project. That's more than most people finish.
You can now build CLI tools
argparse, JSON persistence, error handling, module organization. This is the shape of half the scripts on GitHub — you can write them.
You can read real code
Open any small Python project on GitHub and follow along. You'll recognize the patterns — functions, files, error handling, modules — in whatever you read.
You can learn independently
You don't need a tutorial anymore. You need a goal and the docs. "How do I write a CSV?" → search + read. That's how you keep going from here.
You know what to learn next
Classes (object-oriented Python). Testing (pytest). Web frameworks (Flask, Django). Data (pandas, numpy). The rest of Python is discovery, not prerequisites.
The most important milestone is behind you. The gap between "has never written code" and "has shipped a real project" is a hundred times larger than the gap between "shipped one project" and "shipped ten." You've crossed the big one. Everything from here is accumulation — faster, easier, and more interesting than the first step.
➡️ What's next
Three directions from here. All of them are valid.
❓ Frequently asked questions
What is the best first Python project?
A CLI tool that does something real: expense tracker, to-do list, password manager, markdown converter, or file organizer. What makes it a good first project: uses only concepts you've learned, produces immediate visible output, is small enough to finish in a weekend, and is something you'd actually use. Avoid project tutorials that are 40 hours long or that use frameworks you haven't learned yet.
How should I structure a Python project?
For a small project, three files are enough: main.py for the entry point and CLI parsing, helpers.py or core.py for the logic, and a data file (JSON, CSV, SQLite). Add requirements.txt if you install any packages. Once the project grows, convert to a package: move files into a project/ folder with init.py, then split helpers further as needed. Don't over-engineer the structure for a 500-line project.
Should I use argparse or click for CLI tools?
Use argparse for your first project — it's built into the standard library and teaches you how CLI parsing works. Use click or typer for larger projects where argparse's verbosity becomes painful. argparse handles subcommands (myapp add, myapp list), flags (--verbose), positional arguments, and automatic --help generation. It's enough for 90% of CLI tools.
How do I persist data in a Python CLI app?
Use JSON for small data sets (a few thousand records). Use SQLite for anything larger or with relational structure. JSON is simpler — one file, human-readable, json.dump() and json.load(). Pattern: load everything at startup, modify the in-memory list, save the whole thing on every change. For 10,000 records this is instantaneous; for 1,000,000 it's slow, but you won't hit that with a personal CLI tool.
How do I make a Python script runnable from anywhere?
Three options: (1) Run python path/to/script.py from anywhere. (2) Add a shebang (#!/usr/bin/env python3) and chmod +x, then ./script.py works. (3) Best for a real project: add a setup.py or pyproject.toml with a console_scripts entry point, then pip install -e . installs your tool as a command. Option 3 is what production CLI tools do.
How do I handle errors in a CLI app?
Validate at the boundary — when parsing arguments and when loading data. Use argparse's built-in validation for arguments (type=int, choices=[...], required=True). Wrap file operations in try/except for FileNotFoundError and json.JSONDecodeError. Let unexpected exceptions crash loudly with a traceback — silent failures in CLI tools are worse than crashes because the user doesn't know their data is corrupt.
How do I test a Python CLI project?
Split your code so that all logic is in pure functions that take and return data, and only main.py touches sys.argv and print(). Then test the pure functions with simple assert statements or pytest. For the CLI layer, use subprocess to run the script and check stdout. The key: 90% of your tests should hit pure functions that need no special setup.
Should I publish my Python project to PyPI?
Not for your first project — local use is the goal. If people ask to use your tool, publish to PyPI later: add pyproject.toml with name, version, entry_points, then python -m build && twine upload dist/*. Publishing is a skill worth learning, but it's a distraction from finishing your first project. Ship it locally first.