Tracking Spending

TipHow these exercises work

Each topic starts with a real scenario and a bar model, then three levels of exercises. Level 1 (Guided) fills in the blanks of a finished function. Level 2 (Practice) applies it to new numbers. Level 3 (Challenge) asks a new question with little scaffolding. The R and Python exercises run in your browser: click Run Code to try, Submit Answer to check, and use the hint or solution if you get stuck. Excel exercises have a solution you can reveal.

1 Book Topics

  • Tracking vs. budgeting: tracking records where money actually went; budgeting allocates where it should go; comparing the two creates a feedback loop
  • Capture methods: spending journal, checkbook register, cash envelopes, bank statements; pick one and run it for a full month
  • Categorizing: start with about 10 stable buckets (housing, transportation, groceries, dining out, subscriptions, personal, health, gifts, fun money, other); when in doubt use “Other”, and only promote a new category after seeing it three months in a row
  • Using spreadsheets: bank exports have Date, Description, and Amount columns; a keyword lookup adds the Category column in one pass
  • Monthly review: pull, categorize, compare to budget, and pick one change for next month
  • Online subscriptions: Americans spend about $219/month on subscriptions and underestimate it by $133; audit every quarter and cancel anything unused in 30 days
  • Math: the subscription audit, the opportunity cost of a $20/month subscription invested at 7% for 30 years (about $22,700)

2 Bank Statement Data

A minimal bank export has three columns: Date, Description, and Amount. The code blocks below show how to create an example bank statement file in R, Python, and Excel.

We can simply copy and paste the data into Excel; Date in A, Description in B, Amount in C.

bank_statement in Excel

bank_statement in Excel

In Python, we’ll use polars to create a DataFrame and display the bank statement. This is similar to the tibble we created above.

show/hide
import polars as pl

py_bank_statement = pl.DataFrame({
    "Date": [
        "2026-01-02", "2026-01-02", "2026-01-03", "2026-01-03",
        "2026-01-04", "2026-01-04", "2026-01-05", "2026-01-06",
        "2026-01-07", "2026-01-08", "2026-01-09", "2026-01-10",
        "2026-01-11", "2026-01-12", "2026-01-13", "2026-01-14",
        "2026-01-15"
    ],
    "Description": [
        "Employer Payroll", "Rent", "Comcast Internet", "Trader Joe's",
        "Shell Gas", "Netflix", "Starbucks", "PG&E Electric",
        "AT&T Wireless", "Spotify", "Whole Foods", "Chipotle",
        "Geico Auto Insurance", "NYTimes Digital", "Shell Gas", "Costco",
        "24 Hour Fitness"
    ],
    "Amount": [
         2400.00, -1500.00, -75.00, -82.45, -45.20, -15.99, -6.75,
        -142.30, -85.00, -10.99, -64.12, -14.50, -125.00, -17.00,
         -42.80, -156.78, -39.99
    ]
}).with_columns(pl.col("Date").str.to_date())

py_bank_statement
shape: (17, 3)
Date Description Amount
date str f64
2026-01-02 "Employer Payroll" 2400.0
2026-01-02 "Rent" -1500.0
2026-01-03 "Comcast Internet" -75.0
2026-01-03 "Trader Joe's" -82.45
2026-01-04 "Shell Gas" -45.2
2026-01-04 "Netflix" -15.99
2026-01-05 "Starbucks" -6.75
2026-01-06 "PG&E Electric" -142.3
2026-01-07 "AT&T Wireless" -85.0
2026-01-08 "Spotify" -10.99
2026-01-09 "Whole Foods" -64.12
2026-01-10 "Chipotle" -14.5
2026-01-11 "Geico Auto Insurance" -125.0
2026-01-12 "NYTimes Digital" -17.0
2026-01-13 "Shell Gas" -42.8
2026-01-14 "Costco" -156.78
2026-01-15 "24 Hour Fitness" -39.99
show/hide
r_bank_statement <- tibble::tibble(
  Date = as.Date(c(
    "2026-01-02", "2026-01-02", "2026-01-03", "2026-01-03",
    "2026-01-04", "2026-01-04", "2026-01-05", "2026-01-06",
    "2026-01-07", "2026-01-08", "2026-01-09", "2026-01-10",
    "2026-01-11", "2026-01-12", "2026-01-13", "2026-01-14",
    "2026-01-15"
  )),
  Description = c(
    "Employer Payroll", "Rent", "Comcast Internet", "Trader Joe's",
    "Shell Gas", "Netflix", "Starbucks", "PG&E Electric",
    "AT&T Wireless", "Spotify", "Whole Foods", "Chipotle",
    "Geico Auto Insurance", "NYTimes Digital", "Shell Gas", "Costco",
    "24 Hour Fitness"
  ),
  Amount = c(
     2400.00, -1500.00, -75.00, -82.45, -45.20, -15.99, -6.75,
    -142.30, -85.00, -10.99, -64.12, -14.50, -125.00, -17.00,
     -42.80, -156.78, -39.99
  )
)

r_bank_statement |> knitr::kable()
Date Description Amount
2026-01-02 Employer Payroll 2400.00
2026-01-02 Rent -1500.00
2026-01-03 Comcast Internet -75.00
2026-01-03 Trader Joe’s -82.45
2026-01-04 Shell Gas -45.20
2026-01-04 Netflix -15.99
2026-01-05 Starbucks -6.75
2026-01-06 PG&E Electric -142.30
2026-01-07 AT&T Wireless -85.00
2026-01-08 Spotify -10.99
2026-01-09 Whole Foods -64.12
2026-01-10 Chipotle -14.50
2026-01-11 Geico Auto Insurance -125.00
2026-01-12 NYTimes Digital -17.00
2026-01-13 Shell Gas -42.80
2026-01-14 Costco -156.78
2026-01-15 24 Hour Fitness -39.99

We can view the bank statement in R using knitr::kable().

3 Categorizing Transactions

A bank export only says where the money went (Description). Adding a Category column turns a list of merchants into budget buckets. In R, dplyr::case_when() checks each rule in order and the first match wins; in Python, numpy.select() does the same with a list of conditions and a list of labels; in Excel, IFS() does it one cell at a time. Anything that matches no rule falls into “Other”.

3.1 Level 1: Guided

Fill in the blanks so grocery stores (Trader Joe’s, Whole Foods, Costco) become “Groceries” and everything without a rule becomes “Other”. How many transactions are groceries?

With Description in column B, write an IFS() formula in D2 that labels subscriptions, groceries, and “Other”, then fill it down and count the groceries.

SEARCH() finds a keyword in a cell, ISNUMBER() turns that into true/false, and OR() joins several keywords. The final TRUE, "Other" catches everything else.

=IFS(
  OR(ISNUMBER(SEARCH("Netflix",B2)),ISNUMBER(SEARCH("Spotify",B2)),ISNUMBER(SEARCH("NYTimes",B2)),ISNUMBER(SEARCH("24 Hour Fitness",B2))), "Subscriptions",
  OR(ISNUMBER(SEARCH("Trader Joe",B2)),ISNUMBER(SEARCH("Whole Foods",B2)),ISNUMBER(SEARCH("Costco",B2))), "Groceries",
  TRUE, "Other"
)

IFS() in Excel

IFS() in Excel

Then count them with =COUNTIF(D:D, "Groceries"). The answer is 3.

Separate the store names with | (it means “or”): "Trader Joe|Whole Foods|Costco". The default label is "Other".

desc = statement["Description"]
statement["Category"] = np.select(
    [desc.str.contains("Netflix|Spotify|NYTimes|24 Hour Fitness"),
     desc.str.contains("Trader Joe|Whole Foods|Costco")],
    ["Subscriptions", "Groceries"],
    default="Other"
)
(statement["Category"] == "Groceries").sum()

Separate the store names with | (it means “or”): "Trader Joe|Whole Foods|Costco". The last rule’s label is "Other".

statement |>
  mutate(Category = case_when(
    grepl("Netflix|Spotify|NYTimes|24 Hour Fitness", Description) ~ "Subscriptions",
    grepl("Trader Joe|Whole Foods|Costco", Description) ~ "Groceries",
    TRUE ~ "Other"
  )) |>
  filter(Category == "Groceries") |>
  nrow()

3.2 Level 2: Practice

Next month’s export has three new merchants: Safeway (groceries), Hulu (a subscription), and Uber (transportation). The rules below don’t know them yet, so they land in “Other”. Update the rules so no transaction is left in “Other”.

Your IFS() formula labels the new month’s Safeway, Hulu, and Uber rows as “Other”. Edit it so =COUNTIF(D:D, "Other") returns 0.

Add each new merchant to its group with another ISNUMBER(SEARCH(...)) inside the OR(), and add a Transportation rule:

OR(..., ISNUMBER(SEARCH("Safeway",B2))), "Groceries",
OR(..., ISNUMBER(SEARCH("Hulu",B2))), "Subscriptions",
OR(ISNUMBER(SEARCH("Shell",B2)),ISNUMBER(SEARCH("Uber",B2))), "Transportation",

Re-fill the formula down after editing it.

Add each new merchant to the pattern for its group, separated by |. For example, "Shell|Uber".

desc = statement["Description"]
statement["Category"] = np.select(
    [desc.str.contains("Payroll"),
     desc.str.contains("Rent"),
     desc.str.contains("Trader Joe|Whole Foods|Costco|Safeway"),
     desc.str.contains("Netflix|Spotify|NYTimes|24 Hour Fitness|Hulu"),
     desc.str.contains("Shell|Uber"),
     desc.str.contains("Starbucks|Chipotle")],
    ["Income", "Housing", "Groceries", "Subscriptions", "Transportation", "Dining"],
    default="Other"
)
(statement["Category"] == "Other").sum()

Add each new merchant to the pattern for its group, separated by |. For example, "Shell|Uber".

statement |>
  mutate(Category = case_when(
    grepl("Payroll", Description) ~ "Income",
    grepl("Rent", Description) ~ "Housing",
    grepl("Trader Joe|Whole Foods|Costco|Safeway", Description) ~ "Groceries",
    grepl("Netflix|Spotify|NYTimes|24 Hour Fitness|Hulu", Description) ~ "Subscriptions",
    grepl("Shell|Uber", Description) ~ "Transportation",
    grepl("Starbucks|Chipotle", Description) ~ "Dining",
    TRUE ~ "Other"
  )) |>
  filter(Category == "Other") |>
  nrow()

3.3 Level 3: Challenge

Real bank exports are messy: the same merchant can show up as NETFLIX.COM, Netflix, or netflix. The rule below only matches the exact capitalization, so it finds just 1 of the 3 subscriptions. Change the code so capitalization doesn’t matter, and count the subscriptions again.

Does the IFS() and SEARCH() formula need any change for NETFLIX.COM?

No. SEARCH() ignores capitalization, so SEARCH("Netflix", "NETFLIX.COM") already finds a match. (Its case-sensitive cousin is FIND().)

str.contains() has a case argument. Look at what case=False does.

is_sub = messy["Description"].str.contains("Netflix|Spotify|NYTimes|24 Hour Fitness", case=False)
is_sub.sum()

grepl() has an ignore.case argument.

messy |>
  filter(grepl("Netflix|Spotify|NYTimes|24 Hour Fitness", Description, ignore.case = TRUE)) |>
  nrow()

4 Answering Tracking Questions

Once every row has a category, the book’s three tracking questions take one short pipeline each: what did subscriptions cost, what share of income went to fixed costs, and which variable category was biggest? Spending is stored as negative numbers, so a minus sign turns a spending total positive.

4.1 Level 1: Guided

What did the four subscriptions cost this month?

With Amount in C and Category in D, total the subscriptions as a positive number.

=-SUMIF(D:D, "Subscriptions", C:C)

This returns $83.97.

Pick the rows whose Category is "Subscriptions".

-statement.loc[statement["Category"] == "Subscriptions", "Amount"].sum()

Keep the rows whose Category is "Subscriptions".

statement |>
  filter(Category == "Subscriptions") |>
  summarise(total = -sum(Amount)) |>
  pull(total)

4.2 Level 2: Practice

Fixed costs are housing, utilities, and insurance. What share of income went to fixed costs? Give the answer as a decimal (for example, 0.25 for 25%).

Total the three fixed categories and divide by income (the positive amounts).

=-(SUMIF(D:D,"Housing",C:C)+SUMIF(D:D,"Utilities",C:C)+SUMIF(D:D,"Insurance",C:C))/SUMIF(C:C,">0")

This returns 0.803, or about 80%.

statement["Category"].isin(fixed) picks the fixed-cost rows. Sum their Amount and flip the sign.

fixed = ["Housing", "Utilities", "Insurance"]
income = statement.loc[statement["Amount"] > 0, "Amount"].sum()
fixed_total = -statement.loc[statement["Category"].isin(fixed), "Amount"].sum()
fixed_total / income

filter(Category %in% fixed) keeps the fixed-cost rows; then summarise(total = -sum(Amount)) and pull(total).

fixed <- c("Housing", "Utilities", "Insurance")
income <- statement |> filter(Amount > 0) |> pull(Amount) |> sum()
fixed_total <- statement |> filter(Category %in% fixed) |> summarise(total = -sum(Amount)) |> pull(total)
fixed_total / income

4.3 Level 3: Challenge

Leaving out income, fixed costs, and subscriptions, which category had the most spending? Return its name.

Find the biggest variable category without scrolling through the rows.

Select the data and use Insert → PivotTable with Category in Rows and Amount in Values, then sort smallest to largest (spending is negative). Or list the variable categories in F2:F4 (Groceries, Transportation, Dining), total each in G2 with =-SUMIF(D:D, F2, C:C), fill down, and use:

=INDEX(F2:F4, MATCH(MAX(G2:G4), G2:G4, 0))

The answer is Groceries ($303.35).

Filter out the skip categories with ~statement["Category"].isin(skip), then groupby("Category")["Amount"].sum(). Spending is negative, so the biggest is .idxmin().

skip = ["Income", "Housing", "Utilities", "Insurance", "Subscriptions"]
variable = statement[~statement["Category"].isin(skip)]
variable.groupby("Category")["Amount"].sum().idxmin()

filter(!Category %in% skip), then group_by(Category) and summarise(Total = -sum(Amount)). Finish with slice_max(Total, n = 1) and pull(Category).

skip <- c("Income", "Housing", "Utilities", "Insurance", "Subscriptions")
statement |>
  filter(!Category %in% skip) |>
  group_by(Category) |>
  summarise(Total = -sum(Amount)) |>
  slice_max(Total, n = 1) |>
  pull(Category)

5 The Subscription Audit Math

A $20/month subscription feels trivial. Cancel it and invest the $240 a year instead, and the chart below shows what that money grows into at each checkpoint, split into the money you put in and the growth it earned.

Formula: Annual cost × ((1 + r)^n − 1) ÷ r = Opportunity cost in n years

With the book’s 7% real return (the return after inflation), r = 0.07.

$240 a year invested at 7% instead of a $20/month subscription

$240 a year invested at 7% instead of a $20/month subscription

$240 a year invested at 7% instead of a $20/month subscription

$240 a year invested at 7% instead of a $20/month subscription

Each bar adds another year’s $240 to everything saved so far, and the whole pile grows by 7%. After 30 years you put in $7,200, and growth added about $15,500.

5.1 Level 1: Guided

Fill in the blanks to finish subscription_opportunity_cost(), then find what a $20/month subscription costs over 30 years at 7%.

Put the monthly cost in A2 (20), the years in B2 (30), and the return in C2 (0.07). In D2, write the opportunity cost.

=(A2*12)*((1+C2)^B2-1)/C2

A2*12 turns the monthly cost into a yearly one. =FV(C2, B2, -A2*12) gives the same $22,670.59.

Opportunity Cost in Excel

Opportunity Cost in Excel

A year has 12 monthly payments. The growth factor is raised to the number of years.

def subscription_opportunity_cost(monthly_cost, years, rate=0.07):
    annual_cost = monthly_cost * 12
    return annual_cost * ((1 + rate) ** years - 1) / rate

subscription_opportunity_cost(20, 30)

A year has 12 monthly payments. The growth factor is raised to the number of years.

subscription_opportunity_cost <- function(monthly_cost, years, rate = 0.07) {
  annual_cost <- monthly_cost * 12
  annual_cost * ((1 + rate)^years - 1) / rate
}
subscription_opportunity_cost(20, 30)

5.2 Level 2: Practice

Audit all four subscriptions from the bank statement (Netflix $15.99, Spotify $10.99, NYTimes $17.00, 24 Hour Fitness $39.99). What is their combined opportunity cost over 30 years?

List the four monthly costs in A2:A5, keep 30 and 0.07 in B and C, fill the D2 formula down, and total the column.

=SUM(D2:D5)

This returns $95,182.47. The gym membership alone is $45,330 of it.

The function works on a whole column at once: pass subs["monthly"] and 30.

subs["after_30"] = subscription_opportunity_cost(subs["monthly"], 30)
subs["after_30"].sum()

The function is vectorized: pass the whole monthly column and 30.

subs |>
  mutate(after_30 = subscription_opportunity_cost(monthly, 30)) |>
  summarise(total = sum(after_30)) |>
  pull(total)

5.3 Level 3: Challenge

The audit formula adds money once a year. Subscriptions are really paid monthly. Write monthly_opportunity_cost(monthly_cost, years, rate = 0.07) that invests every month (use a monthly rate and the number of months), then price the survey’s $219/month over 30 years.

Find the 30-year value of $219 invested every month at 7%.

=FV(7%/12, 30*12, -219)

This returns $267,173.65, about $12,000 more than yearly deposits because each month’s money starts growing sooner.

Use monthly_rate = rate / 12 and periods = years * 12, then the same formula with monthly_cost in place of the yearly amount.

def monthly_opportunity_cost(monthly_cost, years, rate=0.07):
    monthly_rate = rate / 12
    periods = years * 12
    return monthly_cost * ((1 + monthly_rate) ** periods - 1) / monthly_rate

monthly_opportunity_cost(219, 30)

Use monthly_rate <- rate / 12 and periods <- years * 12, then the same formula with monthly_cost in place of the yearly amount.

monthly_opportunity_cost <- function(monthly_cost, years, rate = 0.07) {
  monthly_rate <- rate / 12
  periods      <- years * 12
  monthly_cost * ((1 + monthly_rate)^periods - 1) / monthly_rate
}
monthly_opportunity_cost(219, 30)