Compare monthly spending with ledger-cli
Every now and then, I have a higher-than-usual spending month even though I don't remember going crazy with any specific thing.
I've tracked my expenses and income for almost a decade now -- initially with Apple Numbers spreadsheets, and finally migrating all of it to ledger-cli in July of this year. While ledger-cli helps to understand how much is flowing in and out, it's not straightforward to pinpoint what caused the expenses in a month to blow up.
So I figured: why not export the data and analyze it to answer this question myself?
If you're interested in tracking your finances and are considering some form of plain text accounting (e.g., ledger-cli, hledger, beancount), I hope this post is an interesting showcase.
Requirements: ledger-cli and DuckDB.
Spotting the spikes
While checking transactions and totals with ledger-cli is simple, identifying an unusual spike in spending is not as easy, because you can't compare expense categories month-by-month.
In order to identify that, I needed a breakdown like the one in the picture above. This made it easier to eyeball any given category month-by-month and identify spikes.
For example, in the sample report above, the spending in the Expenses:Misc category is much higher than usual in 2025.02 and 2025.08,
accounting for most of the spike in those two months.
Plaintext accounting in 30 seconds
Most people don't track expenses/incomes at all. Some people do it with pen-and-paper or with spreadsheets. Those who want flexibility do it in a format that's easy to write, easy to read, and easy to extend. There are multiple ways to do it, but plaintext will likely outlast all of them.
A transaction in ledger-cli looks like:
2025-11-23 bread and milk - supermarket
Expenses:Groceries 10.50 EUR
Assets:Checking -10.50 EUR
It notes the date, the payee, where the money went, where the money came from, and the amounts. ledger-cli also ensures that each transaction balances out to 0, which is the foundation of double-entry accounting. There can also be multiple sources and destinations, as well as mixed currencies, allowing for more complex transactions, but we won't be using those today.
The ledger-cli tool then provides you with multiple commands to slice and dice these transactions and answer your questions.
Example ledger file
As an example, let's use the following ledger file (main.journal):
main.journal
; declare currencies
commodity EUR
format 1,000.00 EUR
; declare accounts
account Assets:Cash
account Assets:Checking
account Equity:OpeningBalances
account Expenses:Bills
account Expenses:Food
account Expenses:Groceries
account Liabilities:CreditCard
; init balances
2025-10-01 opening balances
Assets:Checking 2000 EUR
Assets:Cash 300 EUR
Liabilities:CreditCard 0 EUR
Equity:OpeningBalances
; last month's expenses
2025-10-01 supermarket
Expenses:Groceries 150 EUR
Assets:Checking
2025-10-02 hotdog
Expenses:Food 6.50 EUR
Assets:Cash
2025-10-03 electricity bill
Expenses:Bills 100 EUR
Assets:Checking
2025-10-04 ice cream
Expenses:Food 10 EUR
Liabilities:CreditCard
; this month's expenses
2025-11-01 supermarket
Expenses:Groceries 120 EUR
Assets:Checking
2025-11-03 electricity bill
Expenses:Bills 200 EUR
Assets:Checking
2025-11-04 ice cream
Expenses:Food 50 EUR
Assets:Cash
Declaring currencies and accounts upfront is not required, but it really helps with keeping a tidy and error-free ledger file (more on this later).
ledger-cli essentials
Before diving into the custom report generation, you should learn some of what ledger-cli can do out of the box.
After noting down your expenses, you may wonder how much money you have left in your asset accounts:
λ ledger --file main.journal balance assets
1,673.50 EUR Assets
243.50 EUR Cash
1,430.00 EUR Checking
--------------------
1,673.50 EUR
Or how much you owe on your credit card:
λ ledger --file main.journal balance liabilities:creditcard
-10.00 EUR Liabilities:CreditCard
Perhaps you want to review your food transactions, to decide if you need to go easier on ice cream:
λ ledger --file main.journal register expenses:food
25-Oct-02 hotdog Expenses:Food 6.50 EUR 6.50 EUR
25-Oct-04 ice cream Expenses:Food 10.00 EUR 16.50 EUR
25-Nov-04 ice cream Expenses:Food 50.00 EUR 66.50 EUR
Note how the last column gives you the running total.
The balance
and register
commands are the bread-and-butter of ledger-cli,
but to dive deeper into our expenses,
we'll need the csv
export functionality.
Month-by-Month report
As mentioned above, comparing month-by-month is not something ledger-cli currently provides natively. Therefore, I came up with a small script that dumps the transactions and does the aggregation in the month-by-month format shown earlier.
I keep this script in the same folder as main.journal and run it via ./report.sh (remember to chmod +x it).
#!/usr/bin/env bash
(
echo "date,code,payee,account,commodity,amount,cleared,notes"
ledger csv Expenses \
--file main.journal \
--date-format %F \
--period 'since 1 year ago'
) |
duckdb -nullvalue '' -c "
create table t as
select strftime(date, '%Y.%m') as ym, account, amount
from read_csv_auto('/dev/stdin');
-- per month, category
(
pivot t on ym
using round(sum(amount), 2)
order by account
)
union all
-- per month totals
(
pivot (select 'Total' as account, ym, amount from t) on ym
using round(sum(amount), 2)
)
"
With the example main.journal above, the output of the report looks like this:
How it works
Let's go over the script.
The first part, left-hand side of the pipe (|), is simply exporting the relevant transactions as CSV to stdout.
echo ...writes the CSV header, according to ledger-cli's default CSV export formatledger csv Expensesexports transactions under theExpensesaccount as CSV--filespecifies which file to read from--date-format %Fspecifies the ISO date format -- this is important to get DuckDB's CSV parser to identify it as an actual date--period 'since 1 year ago'uses the--periodexpression syntax to specify the relevant period
The second part, after the pipe, is the SQL query that does the pivoting month-by-month. Standard SQL requires you to specify the pivoting values you care about which will each become its own column, but DuckDB has a much friendlier pivot statement that does it for you.
It takes the form of pivot tbl on col using agg(value), where:
tblis the table you want to pivot.colis the label column whose values you want to transform into multiple columns.valueis the value column you want to aggregate for each column/row.aggis the aggregation function you want to use (e.g.,sum,max,avg).- Any remaining columns (not specified with
onorusing) will be kept and grouped.
Specifically in the query:
create table t ascreates a new table with the columns we care about, already transforming the date into theYYYY.MM.- the first
pivotcreates the per-category month-by-month breakdown. - the second
pivotcreates the monthly totals by replacing the original value ofaccountwith a value that is equal for all rows (Total) -- once aggregated, the values will not be separated per spending category.
Final advice
- Read up on double-entry accounting if you're going to start with plaintext accounting. Knowing how to model your transactions properly prevents confusion down the road.
- Keep it simple and use a single transactions file. I've tried multiple files, but in the end, errors due to transaction ordering became too common.
- At the very least, use
--strictand--pedantic. These make it so that you can only use accounts, tags, and commodities that have been explicitly declared -- basically free protection against typos. - Make use of a
ledgerrcfile to make ledger-cli commands less repetitive. Here's what mine currently looks like:
# file: ~/.config/ledger/ledgerrc
--date-format %F # ISO date (YYYY-MM-DD)
--sort date # sort by date
--effective # use effective transaction date if there is one
--strict # forbid unknown accounts (good against typos)
--pedantic # elevate --strict warnings to errors
--file main.journal # use `main.journal` if no --file is specified