Query Cookbook
Canonical queries for the most common questions about Treeline data.
Written for AI agents, useful for humans. If you’re an agent: read the conventions first — they’re what make naive queries return wrong numbers — then adapt the queries below. If you’re a human: paste this page into your AI tool of choice as context, or save it as a skill in whatever harness you use.
Conventions (read this first)
Section titled “Conventions (read this first)”Amounts carry their sign. Negative = money out, positive = money in. “Spending” means amount < 0, and you’ll usually want -amount so totals read as positive numbers.
Query the transactions view, and don’t outsmart it. The view already excludes deleted rows, and split transactions are handled for you: splitting soft-deletes the parent, so only the child rows are visible. Do not add parent_transaction_id IS NULL as a “defensive” filter — that excludes every split child and undercounts.
Don’t assume what counts as “spending” or “income” — that’s the user’s definition, not yours. amount < 0 is an outflow, nothing more. The same row — a credit card payment, an investment contribution, a 529 deposit, a Venmo to a friend — is consumption to one user, savings to another, noise to a third. Setups differ just as much: some users track transactions on every account (so a card payment appears twice, an outflow and a matching inflow); others track only some accounts (so a brokerage contribution appears once, as a bare outflow from checking). And tags are entirely user-defined — there is no fixed vocabulary.
So before answering cash-flow questions, discover this user’s definitions instead of supplying your own:
-- Which accounts even carry transactions?SELECT a.name, a.account_type, a.classification, COUNT(t.transaction_id) AS txnsFROM accounts aLEFT JOIN transactions t USING (account_id)GROUP BY a.name, a.account_type, a.classificationORDER BY txns DESC;-- What vocabulary does this user tag with?SELECT tag, COUNT(*) AS nFROM (SELECT UNNEST(tags) AS tag FROM transactions)GROUP BY tag ORDER BY n DESC;-- What are the biggest flows, and how are they tagged?SELECT posted_date, description, amount, account_name, tagsFROM transactionsORDER BY ABS(amount) DESCLIMIT 20;Build your filters from what you learn, and when a large flow is ambiguous, ask the user rather than classify it yourself. Patterns you notice — income closely tracking spending, big untagged outflows — are questions to raise with the user, not conclusions to bake into the numbers.
The queries below exclude rows with list_has_any(tags, ['transfer', 'payment']). That list is a syntactic placeholder showing where the user’s exclusions plug in — not a recommendation of what to exclude. Whatever the list ends up being, apply it at the row level, before UNNEST — a row tagged ['transfer', 'savings'] leaks into per-tag totals through its second tag if you filter after unnesting.
Net worth comes from balance_snapshots, not accounts.balance. Take the latest snapshot per account and sum the raw balances. Balances carry their own sign — liabilities are negative — so do not flip signs by classification; that adds debt back and inflates net worth. Snapshots can be sparse (manual accounts, paused syncs), so time-series queries must carry each account’s last known balance forward rather than bucketing snapshots by period.
Tags are a VARCHAR[] column. Use list_contains(tags, 'foo') for membership (wrap in COALESCE(..., false) — untagged rows are NULL), and UNNEST(tags) in a subquery to group by tag.
Use now()::TIMESTAMP::DATE for “today”. CURRENT_DATE needs the icu extension, which Treeline deliberately doesn’t load (tl query, the desktop app, and MCP all reject it) — the double cast works everywhere.
Net worth right now
Section titled “Net worth right now”WITH latest AS ( SELECT account_id, balance FROM balance_snapshots QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY snapshot_time DESC) = 1)SELECT ROUND(SUM(balance), 2) AS net_worth FROM latest;Latest snapshot per account, raw sum. No sign flipping (see conventions).
Net worth over time
Section titled “Net worth over time”WITH months AS ( SELECT DISTINCT DATE_TRUNC('month', snapshot_time) AS month FROM balance_snapshots),carried AS ( SELECT m.month, s.account_id, s.balance, ROW_NUMBER() OVER ( PARTITION BY m.month, s.account_id ORDER BY s.snapshot_time DESC ) AS rn FROM months m JOIN balance_snapshots s ON s.snapshot_time < m.month + INTERVAL 1 MONTH)SELECT month, ROUND(SUM(balance), 2) AS net_worthFROM carriedWHERE rn = 1GROUP BY monthORDER BY month;For each month, every account contributes its last balance as of that month’s end — not just snapshots taken during the month. Real snapshot data is sparse (manual accounts, paused syncs); bucketing by snapshot month silently drops accounts and produces wild swings.
Current balance per account
Section titled “Current balance per account”WITH latest AS ( SELECT account_id, balance, snapshot_time FROM balance_snapshots QUALIFY ROW_NUMBER() OVER (PARTITION BY account_id ORDER BY snapshot_time DESC) = 1)SELECT a.name, a.account_type, a.classification, l.balance, CAST(l.snapshot_time AS DATE) AS as_ofFROM latest lJOIN accounts a USING (account_id)ORDER BY l.balance DESC;The as_of date matters: a stale snapshot means a stale answer, and saying so builds trust.
Spending by month
Section titled “Spending by month”SELECT DATE_TRUNC('month', posted_date) AS month, ROUND(SUM(-amount), 2) AS spendingFROM transactionsWHERE amount < 0 AND NOT COALESCE(list_has_any(tags, ['transfer', 'payment']), false)GROUP BY monthORDER BY month;The exclusion list is the user’s definition of what doesn’t count (see conventions) — without one, every outflow counts, whatever it is.
Income by month
Section titled “Income by month”SELECT DATE_TRUNC('month', posted_date) AS month, ROUND(SUM(amount), 2) AS incomeFROM transactionsWHERE amount > 0 AND NOT COALESCE(list_has_any(tags, ['transfer', 'payment']), false)GROUP BY monthORDER BY month;Apply the user’s exclusions on the income side too — depending on their setup, inflows can include the receiving side of internal moves (a payment arriving at a tracked credit card is a positive amount there).
Savings rate
Section titled “Savings rate”WITH monthly AS ( SELECT DATE_TRUNC('month', posted_date) AS month, SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) AS income, SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END) AS spending FROM transactions WHERE NOT COALESCE(list_has_any(tags, ['transfer', 'payment']), false) GROUP BY month)SELECT month, income, spending, ROUND(100 * (income - spending) / NULLIF(income, 0), 1) AS savings_rate_pctFROM monthlyORDER BY month;This is one common definition of savings rate — confirm it matches the user’s before presenting it as their savings rate. NULLIF guards months with no income. Partial months (the current one, the first synced one) will look off — consider excluding them when summarizing.
Spending by tag
Section titled “Spending by tag”SELECT tag, ROUND(SUM(-amount), 2) AS total, COUNT(*) AS txnsFROM ( SELECT UNNEST(tags) AS tag, amount FROM transactions WHERE amount < 0 AND posted_date >= now()::TIMESTAMP::DATE - INTERVAL 90 DAY AND NOT COALESCE(list_has_any(tags, ['transfer', 'payment']), false))GROUP BY tagORDER BY total DESC;Transfer exclusion happens inside the subquery, before UNNEST — see conventions. Note a transaction with two tags counts toward both, so tag totals can legitimately exceed total spending.
Spending trend per tag
Section titled “Spending trend per tag”SELECT tag, DATE_TRUNC('month', posted_date) AS month, ROUND(SUM(-amount), 2) AS totalFROM ( SELECT UNNEST(tags) AS tag, posted_date, amount FROM transactions WHERE amount < 0 AND NOT COALESCE(list_has_any(tags, ['transfer', 'payment']), false))GROUP BY tag, monthORDER BY tag, month;Top merchants
Section titled “Top merchants”SELECT description, COUNT(*) AS txns, ROUND(SUM(-amount), 2) AS totalFROM transactionsWHERE amount < 0 AND posted_date >= now()::TIMESTAMP::DATE - INTERVAL 90 DAY AND NOT COALESCE(list_has_any(tags, ['transfer', 'payment']), false)GROUP BY descriptionORDER BY total DESCLIMIT 10;description is the raw bank string, so the same merchant can appear under several variants — good enough for a top-10, but say so when it matters.
Recurring charges & subscriptions
Section titled “Recurring charges & subscriptions”SELECT description, COUNT(DISTINCT DATE_TRUNC('month', posted_date)) AS months_seen, ROUND(AVG(-amount), 2) AS avg_amount, ROUND(MIN(-amount), 2) AS min_amount, ROUND(MAX(-amount), 2) AS max_amountFROM transactionsWHERE amount < 0GROUP BY descriptionHAVING months_seen >= 3 AND (MAX(-amount) - MIN(-amount)) / NULLIF(AVG(-amount), 0) < 0.25ORDER BY avg_amount DESC;Heuristic: same description in 3+ distinct months with amounts within ±25% of each other. Catches subscriptions and steady bills without relying on tags; surface min ≠ max rows as possible price changes.
Largest expenses this month
Section titled “Largest expenses this month”SELECT posted_date, description, ROUND(-amount, 2) AS spent, tagsFROM transactionsWHERE amount < 0 AND posted_date >= DATE_TRUNC('month', now()::TIMESTAMP::DATE) AND NOT COALESCE(list_has_any(tags, ['transfer', 'payment']), false)ORDER BY amount ASCLIMIT 15;Untagged transactions
Section titled “Untagged transactions”SELECT posted_date, description, amount, account_nameFROM transactionsWHERE tags IS NULL OR LEN(tags) = 0ORDER BY posted_date DESCLIMIT 25;The review queue. If you’re tagging on the user’s behalf, propose tags and let them confirm — check their existing tag vocabulary first (SELECT DISTINCT UNNEST(tags) FROM transactions).
Budget vs actual (Budget plugin)
Section titled “Budget vs actual (Budget plugin)”The Budget plugin stores a complete category set per month in plugin_budget.categories — there is no template that carries forward, so a month with no rows simply hasn’t been set up. If the tables are missing or contain no rows at all, the user likely doesn’t have the Budget plugin installed — say so rather than reporting a zero budget.
The plugin’s own matching rules, which your query must reproduce to agree with what the user sees on screen:
- A category matches transactions by tag: any of
tagswhenrequire_allis false, all of them when true. amount_signoptionally restricts to inflows ('positive') or outflows ('negative') — used when one tag (a rental property, say) carries both income and expenses.- Actuals bucket by
transaction_date, not theposted_dateused elsewhere on this page. - Expense actuals are sign-flipped so spending reads positive; income actuals are kept as-is.
- Rollovers (
plugin_budget.rollovers) carry leftover budget between months, keyed by category name; a category’s balance is its incoming rollovers plus its variance.
WITH cats AS ( SELECT * FROM plugin_budget.categories WHERE month = strftime(now()::TIMESTAMP, '%Y-%m')),roll_in AS ( SELECT to_category, SUM(amount) AS rolled_in FROM plugin_budget.rollovers WHERE to_month = strftime(now()::TIMESTAMP, '%Y-%m') GROUP BY to_category)SELECT c.type, c.name, c.expected, ROUND(COALESCE(SUM(CASE WHEN c.type = 'expense' THEN -t.amount ELSE t.amount END), 0), 2) AS actual, ROUND(COALESCE(r.rolled_in, 0), 2) AS rolled_in, ROUND(COALESCE(r.rolled_in, 0) + CASE WHEN c.type = 'income' THEN COALESCE(SUM(t.amount), 0) - c.expected ELSE c.expected - COALESCE(SUM(-t.amount), 0) END, 2) AS balanceFROM cats cLEFT JOIN transactions t ON strftime(t.transaction_date, '%Y-%m') = c.month AND (CASE WHEN c.require_all THEN list_has_all(COALESCE(t.tags, []), c.tags) ELSE list_has_any(COALESCE(t.tags, []), c.tags) END) AND (c.amount_sign IS NULL OR (c.amount_sign = 'positive' AND t.amount > 0) OR (c.amount_sign = 'negative' AND t.amount < 0))LEFT JOIN roll_in r ON r.to_category = c.nameGROUP BY c.type, c.name, c.expected, r.rolled_in, c.sort_orderORDER BY c.type DESC, c.sort_order;Swap the month filter for any 'YYYY-MM' string to look at past months. A positive balance on an expense category is room left to spend; negative is overspend.
Setting up a new budget month
Section titled “Setting up a new budget month”Because each month is self-contained, someone — the user or their agent — has to create it. First see what exists:
SELECT month, COUNT(*) AS categories, ROUND(SUM(expected) FILTER (WHERE type = 'income'), 2) AS planned_income, ROUND(SUM(expected) FILTER (WHERE type = 'expense'), 2) AS planned_spendingFROM plugin_budget.categoriesGROUP BY monthORDER BY month;If the latest month is behind strftime(now()::TIMESTAMP, '%Y-%m'), copy it forward — same categories, fresh UUIDs:
INSERT INTO plugin_budget.categories (category_id, month, type, name, expected, tags, require_all, amount_sign, sort_order)SELECT uuid()::VARCHAR, strftime(now()::TIMESTAMP, '%Y-%m'), type, name, expected, tags, require_all, amount_sign, sort_orderFROM plugin_budget.categoriesWHERE month = (SELECT max(month) FROM plugin_budget.categories WHERE month < strftime(now()::TIMESTAMP, '%Y-%m')) AND NOT EXISTS (SELECT 1 FROM plugin_budget.categories WHERE month = strftime(now()::TIMESTAMP, '%Y-%m'));This is a write, so it needs tl query --allow-writes or the MCP query_write tool; the NOT EXISTS guard makes it safe to run repeatedly. The copied expected amounts are last month’s plan, not this month’s — review them with the user afterward rather than assuming they still apply.
Exploring further
Section titled “Exploring further”SELECT table_name, table_typeFROM information_schema.tablesWHERE table_schema = 'main';DESCRIBE transactions (or any table) shows columns. The full schema reference is at Database Schema. Prefer the views (transactions, accounts, balance_snapshots) — the sys_* tables carry raw provider data and include soft-deleted rows.