Project: CSV Expense Tracker
Read CSV rows in memory and calculate reliable expense totals by category.
Video lesson: Project: CSV Expense Tracker
The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube
Jump to a chapter
Read the video transcript
Select a timestamp to watch that moment on YouTube.
- 00:00
Imagine that you exported a few expenses and want a quick answer to a basic question: how much did I spend on Food, and how much on Travel? The sample is small enough to check by hand. Food has twelve dollars and fifty cents in one row, then three dollars in another. Travel has four dollars and twenty-five cents. Before writing code, predict the report: Food should be fifteen dollars and fifty cents, and Travel should be four dollars and twenty-five cents. The interesting part is how to get there reliably. A CSV file looks like lines separated by commas, but it has rules for quoted values, column names, and text fields.
- 00:41
In this lesson we will build an expense tracker in the browser's Python console. We will run a broken version, see why it is wrong, then use Python's CSV reader and Decimal values to make a checked report. The sample stays in memory, so no personal bank data or file upload is needed. Open the companion project page if you want to pause and try each example yourself. First, let us write the tempting shortcut. Split every row at its comma, take the second piece as the amount, and append it to a running total. When I run the first two rows, Python prints twelve point five zero three point zero zero. That is not fifteen point five zero.
- 01:23
It is two pieces of text stuck together. CSV readers return text, and we did not convert those amount strings into numbers. This is a dangerous failure because the program runs and produces something that looks vaguely numeric. Now suppose the category itself is Food, snacks, correctly surrounded by CSV quotes. The same split produces three pieces. Assigning those pieces to the two variables category and amount raises ValueError. We have two separate faults: using text concatenation for money, and parsing CSV by hand. Fixing only one will leave the other bug. In a real import, a quoted comma can appear in a category, merchant, note, or address.
- 02:08
This is why a parser exists. Pause here and explain what you expect the parser to return before we replace the shortcut. This short diagnostic program answers what the CSV reader actually gives us. StringIO makes an in-memory text stream from the sample string. The reader can work with that stream just as it would with a local text file, but here it never touches your computer's filesystem. DictReader reads the first line as field names. Each following row is a dictionary with a category field and an amount field. Notice that Food, snacks stayed together. The quote marks are CSV syntax; they are not part of the category value.
- 02:50
Also notice that two point four zero is shown inside quotes in the dictionary output. The amount is still a string. DictReader solves the parsing problem, but it does not choose our numeric type or calculate totals for us. That separation is helpful. We can inspect the parsed rows, decide what values mean, and only then add them. If the header order changes to amount,category, the named fields can still be selected correctly. The companion guide runs that variation, so you can verify it yourself. Here is the working version. For each parsed row, trim the category so an accidental space around Travel does not produce a second category.
- 03:33
Build a Decimal amount from the original text. For this small money report, that preserves the decimal values written in the CSV; converting a float first could bring in an approximate binary value. The totals dictionary maps each category to its running Decimal sum. On a category's first row, get returns Decimal zero. On a later row, it returns the previous subtotal. Food starts at twelve fifty, then three dollars is added, producing fifteen fifty. The function returns the data instead of printing inside the loop. That lets another caller reuse the totals without scraping output.
- 04:14
The display loop sorts the category keys for a stable report and formats each amount to two decimal places. Sorting is for presentation; it does not change the totals. Run the function and compare its two lines to the prediction from the opening. Then use the site's Try this example button to load the code into the console and change one Food amount. Predict the new subtotal before pressing Run. A single happy-path report is not enough evidence that the project works. The first assertion checks repeated categories: it would fail if we accidentally replaced the earlier Food amount instead of adding to it. The second checks a quoted comma inside a category: it would fail if manual string splitting crept back into the solution.
- 05:01
The third checks an empty report that contains only the header: zero data rows should produce an empty dictionary without a crash. Those are small, deliberate checks of distinct behaviors. The website's practical task starts with a blank function body, and its checker uses additional inputs, so copying a printed answer will not pass. Complete the function, run your own examples, select Check task, and finish the three-question quiz to mark the project complete. As an extension, think about invalid data. What should happen if an amount is written as oops or the header is missing? The deeper guide shows one explicit error path.
- 05:42
A production expense importer also needs rules for currency, refunds, dates, and private data. Our browser project deliberately stops at a clear, verified category report. The link below the video opens the full lesson, runnable examples, mistakes, and references from the official Python documentation.
Understand the concept
An expense report is a useful small project because it turns rows of data into a result you can check. Our sample has a category and an amount on each row. The goal is to add amounts for matching categories and print a predictable summary. We use csv.DictReader so column names tell us what each value means, even if the column order changes.
The browser console works with a text sample held in memory. io.StringIO presents that text as a file-like stream to the CSV reader; it does not create or modify a file on your computer. The reader returns strings, so each amount must be converted before addition. Decimal is a good choice for exact decimal amounts in this introductory report. Build Decimal from the CSV string, not from a float that may already contain a binary rounding approximation.
The function returns a dictionary from category names to Decimal totals. The caller decides how to display it. Sorting the keys makes the printed example stable, and :.2f formats every displayed amount with two decimal places. This version assumes a header row named category,amount and valid numeric amounts. A production tool would validate missing fields, currency, duplicates, and import errors before accepting someone else's data.
- csv.DictReader maps each row to named fields
- StringIO lets the browser parse an in-memory CSV sample
- Decimal adds decimal money values without first converting them to float
See it step by step
Read the code, predict the output, then compare it with the result.
01. Read named fields from CSV text
import csv
from io import StringIO
text = """category,amount
Food,12.50
Travel,4.25
"""
for row in csv.DictReader(StringIO(text)):
print(row["category"], row["amount"])Food 12.50
Travel 4.25DictReader uses the first line as field names and yields two dictionaries. The amount values are still strings.
02. Add exact decimal amounts
from decimal import Decimal
food = Decimal("12.50") + Decimal("3.00")
print(food)
print(f"${food:.2f}")15.50
$15.50Construct Decimal values from the original decimal strings, then format the result only when displaying it.
03. Accumulate by category
from decimal import Decimal
rows = [("Food", "12.50"), ("Travel", "4.25"), ("Food", "3.00")]
totals = {}
for category, amount in rows:
totals[category] = totals.get(category, Decimal("0")) + Decimal(amount)
for category in sorted(totals):
print(f"{category}: ${totals[category]:.2f}")Food: $15.50
Travel: $4.25get supplies zero for a new category. A repeated Food row adds to the previous Food total rather than replacing it.
A closer look
Follow the reasoning, inspect each result, then try the suggested changes in the console below.
Treat the header as a contract
A CSV row is not safely parsed by splitting on a comma. A quoted category may contain a comma of its own, and the fields might appear in a different order. Start by looking at the header. DictReader turns each following row into a dictionary using those header names. This means row['category'] still selects the category when amount happens to be the first column.
StringIO supplies a text stream from a string so this project works in the browser console without asking for a file upload. It is a useful bridge between a fixed example and a later local program that opens an actual CSV file. Print the parsed row before calculating anything. The output below shows that the category Food, snacks remains one value and the amount is still the string 2.40.
import csv
from io import StringIO
text = """amount,category
2.40,"Food, snacks"
3.00,Travel
"""
for row in csv.DictReader(StringIO(text)):
print(f"{row['category']}: {row['amount']}")Food, snacks: 2.40
Travel: 3.00- DictReader reads amount and category from the header even though their order differs from the main task.
- The quoted comma belongs to one category field; a plain split(',') would break that row.
- Swap the two data rows and predict the printed order before running again.
Accumulate money as decimal values
Every amount read from the CSV is a string. Adding strings concatenates them, so 12.50 followed by 3.00 becomes 12.503.00 instead of 15.50. Convert the text to Decimal first. For money-like sample values, constructing Decimal directly from a decimal string avoids importing a float's approximate binary representation into the calculation. The dictionary can then hold Decimal totals under each category.
Use totals.get(category, Decimal('0')) to fetch the prior subtotal or an initial zero. Add the current amount and assign the result back under the same key. That handles both the first row for Food and its later repeat. The returned dictionary remains useful for other output formats; sorting and f-string formatting belong to the display step, after the arithmetic is complete.
from decimal import Decimal
rows = [("Food", "12.50"), ("Travel", "4.25"), ("Food", "3.00")]
totals = {}
for category, amount_text in rows:
amount = Decimal(amount_text)
totals[category] = totals.get(category, Decimal("0")) + amount
for category in sorted(totals):
print(f"{category}: ${totals[category]:.2f}")Food: $15.50
Travel: $4.25- The first Food row starts from Decimal zero and stores 12.50.
- The second Food row reads the stored value and adds 3.00, producing 15.50.
- Insert a third category and predict where it appears in the sorted report.
Verify empty data and reject bad amounts
A header-only CSV is a useful boundary case. DictReader has no data rows to yield, so an accumulator starts and ends empty. That should be a valid empty report rather than an exception. A malformed amount is different: silently treating it as zero would make the report look plausible while hiding a data problem. Raise a clear error when conversion fails, and decide at the calling boundary how to show it.
The short validator below demonstrates that distinction without changing the core challenge. Decimal raises InvalidOperation for a value such as 'oops'. We catch that specific failure and report which text was invalid. A more complete importer would also check for missing headers, empty categories, currency symbols, and the desired policy for refunds. Keep the verified exercise narrow before adding those rules.
import csv
from decimal import Decimal, InvalidOperation
from io import StringIO
def read_total(text):
total = Decimal("0")
for row in csv.DictReader(StringIO(text)):
try:
total += Decimal(row["amount"])
except InvalidOperation:
raise ValueError(f"Invalid amount: {row['amount']}")
return total
print(read_total("amount\n"))
try:
print(read_total("amount\noops\n"))
except ValueError as error:
print(error)0
Invalid amount: oops- The header-only input executes zero loop iterations and returns Decimal zero.
- The bad text reaches Decimal conversion and becomes a named ValueError for the caller.
- Change oops to 2.50 and predict which branch runs.
Try it in Python
Edit the example and run it. Python starts in your browser the first time you click Run.
Python console
Ready to runNeed input()? Add one value per line
Your output appears here.
Need a hint?
Inside the loop, use category = row['category'].strip() and amount = Decimal(row['amount']). Then update totals[category] with totals.get(category, Decimal('0')) + amount.
Complete the task and select Check task to verify your code.
Quick quiz
Three questions. You can change your answers and try again.
Typical mistakes
Everyone meets these errors. See what causes them and how to fix them.
Adding amount strings
total = "12.50" + "3.00"total = Decimal("12.50") + Decimal("3.00")What happens: The result is the text 12.503.00, not a money total.
Convert each string to Decimal before adding it. Build Decimal from the source text to keep decimal values exact.
Splitting a CSV row on commas
category, amount = line.split(",")for row in csv.DictReader(StringIO(csv_text)):
category = row["category"]What happens: A quoted category such as Food, snacks is split into too many pieces.
The csv module understands quoted fields and uses the header names.
Replacing an earlier category total
totals[category] = amounttotals[category] = totals.get(category, Decimal("0")) + amountWhat happens: A second Food row hides the first Food expense.
Use the previous total as the starting point when a category appears again.