Somewhere in your bank account there is probably a $6 server from a side project you stopped working on, and it has been billing you every month since. Scrolling through PDF statements to find it is a chore, so the plan here is to turn a year of statements into a CSV with a bank file tool and let about 40 lines of Python hunt down every recurring charge.
People are bad at keeping track of this. In a 2022 survey by C+R Research, Americans guessed they spent about $86 a month on subscriptions, and the real figure came out at around $219. Monthly charges are small and easy to miss among coffee and groceries. Yearly ones are worse, because a $99 Apple Developer Program renewal or a domain for a project that never shipped shows up once and is forgotten for another twelve months.
Export thirteen months of statements rather than twelve, so that anything billed once a year appears twice and the script can see the pattern. Convert the PDFs to CSV and you end up with one row per transaction. The code below assumes three columns called date, description and amount, with money going out as negative numbers, so rename the headers when loading if yours differ. Several accounts can be merged into one file first, since a subscription doesn't care which card it lands on.
The fiddly part is the merchant name. Banks rarely print the same text twice. A Spotify plan paid through PayPal arrives as PAYPAL *SPOTIFY P4821 one month and PAYPAL *SPOTIFY P9034 the next, Netflix often brings a phone number along, and card terminals add store numbers after a #. Grouping on the raw description would split one subscription into a dozen merchants that each appear once.
A blunt rule works well enough. Upper-case the text, split it on spaces, asterisks and hash signs, throw away any token containing a digit, drop a short list of filler words like POS, PAYPAL and SQ, and keep the first two words that remain. The result is not always pretty, and Netflix comes out as NETFLIX.COM CA, but it is consistent, which is all a grouping key needs.
Then comes the cadence. "Monthly" almost never means exactly 30 days. A charge on the 31st slides to the 30th or the 28th, and some banks post weekend payments on Monday. So the script measures the gap in days between consecutive charges from the same merchant and checks whether at least three quarters of those gaps fall inside a window: 6 to 8 days for weekly, 27 to 33 for monthly and 355 to 375 for yearly.
One more filter keeps the results clean. You probably visit the same supermarket every week, and that pattern is regular enough to pass the cadence test. What gives it away is the amount, which jumps from $40 to $140 depending on the week. Requiring the standard deviation of the amounts to stay within 15% of their average removes groceries and fuel while keeping subscriptions, including ones that went up in price partway through the year.
Here is the whole thing:
import re
import pandas as pd
NOISE = {"POS", "PURCHASE", "CARD", "DEBIT", "PAYPAL", "SQ", "VISA", "RECURRING"}
CADENCES = {"weekly": (6, 8, 52), "monthly": (27, 33, 12), "yearly": (355, 375, 1)}
def merchant(description):
tokens = re.split(r"[\s*#]+", description.upper())
words = [t for t in tokens if t and t not in NOISE and not re.search(r"\d", t)]
return " ".join(words[:2])
def cadence(gaps):
for name, (low, high, per_year) in CADENCES.items():
if gaps.between(low, high).mean() >= 0.75:
return name, per_year
return None, 0
df = pd.read_csv("statements.csv", parse_dates=["date"])
spend = df[df["amount"] < 0].assign(amount=lambda d: -d["amount"])
spend["merchant"] = spend["description"].map(merchant)
found = []
for name, group in spend.sort_values("date").groupby("merchant"):
gaps = group["date"].diff().dt.days.dropna()
if len(gaps) == 0:
continue
label, per_year = cadence(gaps)
steady = group["amount"].std(ddof=0) <= 0.15 * group["amount"].mean()
if label and steady:
first, last = group["amount"].iloc[0], group["amount"].iloc[-1]
found.append({
"merchant": name, "every": label, "charges": len(group),
"last_charge": group["date"].iloc[-1].date(), "amount": last,
"price_change": round(last - first, 2), "per_year": round(last * per_year, 2),
})
report = pd.DataFrame(found).sort_values("per_year", ascending=False)
print(report.to_string(index=False))
print(f"\nTotal per year: {report['per_year'].sum():.2f}")
On a sample file with thirteen months of made-up transactions, the output looks like this:
| merchant | every | charges | last_charge | amount | price_change | per_year |
|---|---|---|---|---|---|---|
| CITY CLIMBING | weekly | 57 | 2026-09-29 | 12.00 | 0.0 | 624.00 |
| NETFLIX.COM CA | monthly | 13 | 2026-09-14 | 17.99 | 2.5 | 215.88 |
| SPOTIFY | monthly | 13 | 2026-09-03 | 11.99 | 0.0 | 143.88 |
| APPLE.COM/BILL | yearly | 2 | 2026-09-19 | 99.00 | 0.0 | 99.00 |
| DIGITALOCEAN.COM | monthly | 13 | 2026-09-30 | 6.00 | 0.0 | 72.00 |
Total per year: 1154.76
Sorting by yearly cost changes how the list feels. A $12 weekly class sounds cheap until it appears at the top as $624 a year, above every streaming service combined. The price_change column is the other one to look at. Streaming services have raised their prices several times in recent years, and a $2.50 increase on a single plan rarely gets noticed when it arrives in a statement instead of an email.
The last_charge column tells you what is still alive. Anything billed in the last month or so is active, while a monthly merchant whose last charge was in spring has probably been canceled already. What remains is a short list to go through, one subscription at a time, and the yearly ones are worth copying into a calendar a week before they renew.
There is room to grow from here. Adding the merchant names you have already reviewed to a small ignore.txt keeps the next report short. A cron job can run the script on the first of each month against the newest statement, and if you would rather skip CSV parsing altogether, the same transactions can be exported as JSON and loaded with pd.read_json.
