Skip to content

Querying Data

Finzytrack provides two query engines for accessing your financial data: SQL (SQLite) and BQL (Beancount Query Language). Both are available in the Query view for ad-hoc queries, and in dashboard recipes for widget data.

SQL (SQLite)BQL (Beancount Query Language)
Data sourceSQLite export of your ledgerBeancount entries directly (in-memory)
TableExplicit FROM postingsImplicit — no table name needed
SyntaxStandard SQLite SQLSQL-like but with Beancount-specific extensions
AggregationSUM(CAST(amount AS REAL))COST(SUM(position))
Pattern matchingLIKE operator~ (regex) operator
Date functionsstrftime()YEAR(), MONTH(), DAY()
Best forComplex joins, window-style queries, dashboard recipesQuick account lookups, register views, balance queries
Official docsSQLite SQLBeancount Query Language

In dashboard recipes, set the widget’s dbType field to "sqlite" (default) or "beanquery" to choose the engine.


SQL queries run against a SQLite export of your Beancount ledger. This is the default and recommended engine for dashboard recipes.

The database contains two groups of tables:

  • postings — a denormalized, analytics-optimized table with one row per posting. This is the primary table for dashboard widgets and aggregate queries.
  • Ledger mirror tables — normalized tables covering all other Beancount directives and computed state: accounts, account_balances, commodities, prices, balance_assertions, lots, and more.

Most queries only need the postings table. The ledger mirror tables are useful for specialized widgets like price history charts, lot tracking views, or balance assertion reports.

The postings table contains one row per posting. Each transaction has two or more postings that sum to zero (double-entry accounting).

These columns have the same value for all postings within a transaction.

ColumnTypeDescription
transaction_idTEXTUnique transaction identifier (UUID).
transaction_content_hashTEXTSHA256 content-based hash of the transaction.
transaction_dateTEXTDate in YYYY-MM-DD format.
transaction_flagTEXT'*' (cleared) or '!' (pending).
transaction_payeeTEXTPayee or merchant name.
transaction_narrationTEXTTransaction description.
transaction_tagsTEXTJSON array of tag strings.
transaction_linksTEXTJSON array of link strings.
transaction_metadata_jsonTEXTJSON object of transaction-level metadata from the ledger.
ColumnTypeDescription
posting_idINTEGERAuto-incrementing primary key.
accountTEXTFull colon-separated account path (e.g., Expenses:Food:Groceries).
account_typeTEXTFirst segment: Assets, Liabilities, Equity, Income, or Expenses.
amountTEXTPosting amount as a Decimal-as-string (e.g. "100.00"). Positive = debit, negative = credit. Wrap with CAST(amount AS REAL) for SUM/AVG/arithmetic — see Money column note below.
currencyTEXTCurrency code (e.g., "USD", "INR").
cost_amountTEXTCost basis amount as Decimal-as-string (for investments with cost tracking). Cast with CAST(cost_amount AS REAL) for arithmetic.
cost_currencyTEXTCost basis currency.
price_amountTEXTPrice conversion amount as Decimal-as-string. Cast with CAST(price_amount AS REAL) for arithmetic.
price_currencyTEXTPrice conversion currency.
source_accountTEXTThe originating Assets or Liabilities account for this transaction. Computed from the other posting(s).
source_account_typeTEXTAccount type of the source_account.
posting_metadata_jsonTEXTJSON object of posting-level metadata from the ledger.
ColumnTypeDescription
yearINTEGERYear extracted from transaction_date.
monthINTEGERMonth (1-12) extracted from transaction_date.
quarterINTEGERQuarter (1-4).
year_monthTEXTYear and month as YYYY-MM.

amount, cost_amount, and price_amount are stored as TEXT holding the string form of an arbitrary-precision Decimal (e.g. "1234.5678"). This preserves the exact precision of the source ledger — a value like 0.12345678 BTC round-trips without binary-float rounding error.

The trade-off is at aggregation. SQLite has no exact-decimal arithmetic, so when you sum or average these columns you must cast to REAL:

-- Correct: explicit cast for aggregation
SELECT account, SUM(CAST(amount AS REAL)) AS total
FROM postings
GROUP BY account
-- Also correct: SQLite implicit cast works for plain comparisons
SELECT * FROM postings WHERE amount > 0

A bare SUM(amount) will appear to work — SQLite silently coerces — but the convention in this project is to write the CAST(... AS REAL) explicitly so the float aggregation is visible to anyone reading the query.

The aggregation error from float conversion grows as √N × machine epsilon; for tens of thousands of postings it’s around 10⁻¹², far below display precision. Single-row reads stay exact because no aggregation happens.

Beancount uses double-entry accounting. Every transaction has postings that sum to zero. Understanding the sign of each account type is essential for writing correct queries.

Account TypeSignMeaningTo Display as Positive
ExpensesPositive (debit)Money spentUse SUM(CAST(amount AS REAL)) directly
IncomeNegative (credit)Money earnedUse -SUM(CAST(amount AS REAL)) or SUM(CAST(amount AS REAL)) * -1
AssetsPositive (debit)What you ownUse SUM(CAST(amount AS REAL)) directly
LiabilitiesNegative (credit)What you oweUse -SUM(CAST(amount AS REAL)) for outstanding balance

Common derived values:

  • Net worth = SUM(CAST(amount AS REAL)) WHERE account_type IN ('Assets', 'Liabilities')
  • Savings = Income - Expenses = SUM(CASE WHEN account_type = 'Income' THEN -CAST(amount AS REAL) ELSE 0 END) - SUM(CASE WHEN account_type = 'Expenses' THEN CAST(amount AS REAL) ELSE 0 END)

Your ledger may contain multiple currencies (e.g., USD and INR). Never sum amounts across different currencies. Always do one of:

Group by currency — show each currency separately:

SELECT currency, SUM(CAST(amount AS REAL)) AS amount
FROM postings
WHERE account_type = 'Expenses' AND year = :year
GROUP BY currency
HAVING amount != 0

Filter to one currency — let the user choose:

SELECT account, SUM(CAST(amount AS REAL)) AS total
FROM postings
WHERE account_type = 'Expenses' AND currency = :currency
GROUP BY account
  • SQLite-compatible only. No PostgreSQL or MySQL syntax.
  • Only SELECT statements are allowed (and WITH for CTEs).
  • Use strftime() for date operations — not DATE_TRUNC or EXTRACT.
  • Use :paramName for parameter placeholders in dashboard recipes.
  • Use HAVING to filter out zero-value rows (e.g., HAVING amount != 0).
  • Include ORDER BY when results have a natural ordering.
  • Use LIMIT to avoid returning excessive rows.
  • Each transaction has 2+ postings — be careful not to double-count. Use COUNT(DISTINCT transaction_id) for transaction counts.
  • Filter months with CAST(strftime('%m', transaction_date) AS INTEGER) = :month, or use the year_month column.
SELECT currency, SUM(CAST(amount AS REAL)) * -1 AS amount
FROM postings
WHERE account_type = 'Income' AND year = :year
GROUP BY currency
HAVING amount != 0
ORDER BY currency

Total expenses for a year (multi-currency)

Section titled “Total expenses for a year (multi-currency)”
SELECT currency, SUM(CAST(amount AS REAL)) AS amount
FROM postings
WHERE account_type = 'Expenses' AND year = :year
GROUP BY currency
HAVING amount != 0
ORDER BY currency
SELECT currency,
SUM(CASE WHEN account_type IN ('Assets', 'Liabilities') THEN CAST(amount AS REAL) ELSE 0 END) AS amount
FROM postings
GROUP BY currency
HAVING amount != 0
ORDER BY currency

Monthly expenses for a year (single currency)

Section titled “Monthly expenses for a year (single currency)”
SELECT year_month,
SUBSTR('JanFebMarAprMayJunJulAugSepOctNovDec',
(CAST(strftime('%m', year_month || '-01') AS INTEGER) - 1) * 3 + 1, 3) AS month_label,
SUM(CAST(amount AS REAL)) AS expenses
FROM postings
WHERE account_type = 'Expenses'
AND year = :year AND currency = :currency
GROUP BY year_month
ORDER BY year_month

Monthly income vs expenses (for grouped bar chart)

Section titled “Monthly income vs expenses (for grouped bar chart)”
SELECT year_month,
SUBSTR('JanFebMarAprMayJunJulAugSepOctNovDec',
(CAST(strftime('%m', year_month || '-01') AS INTEGER) - 1) * 3 + 1, 3) AS month_label,
year_month || '-01' AS dateFrom,
date(year_month || '-01', '+1 month', '-1 day') AS dateTo,
SUM(CASE WHEN account_type = 'Income' THEN -CAST(amount AS REAL) ELSE 0 END) AS income,
SUM(CASE WHEN account_type = 'Expenses' THEN CAST(amount AS REAL) ELSE 0 END) AS expenses,
SUM(CASE WHEN account_type = 'Income' THEN -CAST(amount AS REAL) ELSE 0 END) -
SUM(CASE WHEN account_type = 'Expenses' THEN CAST(amount AS REAL) ELSE 0 END) AS savings
FROM postings
WHERE year = :year AND currency = :currency
AND account_type IN ('Income', 'Expenses')
GROUP BY year_month
ORDER BY year_month
SELECT account, SUM(CAST(amount AS REAL)) AS total
FROM postings
WHERE account_type = 'Expenses'
AND year = :year AND currency = :currency
GROUP BY account
HAVING total > 0
ORDER BY total DESC
LIMIT :limit

Expense breakdown for a month (for treemap or pie chart)

Section titled “Expense breakdown for a month (for treemap or pie chart)”
SELECT REPLACE(account, 'Expenses:', '') AS name,
account,
SUM(CAST(amount AS REAL)) AS value
FROM postings
WHERE account_type = 'Expenses'
AND year = :year
AND CAST(strftime('%m', transaction_date) AS INTEGER) = :month
AND currency = :currency
GROUP BY account
HAVING value > 0
ORDER BY value DESC
SELECT COUNT(DISTINCT transaction_id) AS value
FROM postings
WHERE year = :year

Expenses by account and month (for pivot table)

Section titled “Expenses by account and month (for pivot table)”
SELECT account, year_month, SUM(CAST(amount AS REAL)) AS amount
FROM postings
WHERE account_type = 'Expenses'
AND year = :year AND currency = :currency
GROUP BY account, year_month
ORDER BY account, year_month
SELECT currency,
SUM(CASE WHEN account_type = 'Income' THEN -CAST(amount AS REAL) ELSE 0 END) -
SUM(CASE WHEN account_type = 'Expenses' THEN CAST(amount AS REAL) ELSE 0 END) AS amount
FROM postings
WHERE year = :year AND account_type IN ('Income', 'Expenses')
GROUP BY currency
HAVING amount != 0
ORDER BY currency

These tables provide a complete, normalized view of your Beancount ledger beyond transactions. They are populated automatically whenever your ledger changes.

One row per account (Open directive). Close directives set the close_date.

ColumnTypeDescription
nameTEXT (PK)Full account path (e.g., Assets:Bank:Checking).
open_dateTEXTDate the account was opened (YYYY-MM-DD).
close_dateTEXTDate closed, or NULL if still open.
currencies_jsonTEXTJSON array of allowed currencies (e.g., ["USD", "EUR"]).
bookingTEXTBooking method: STRICT, FIFO, LIFO, AVERAGE, HIFO, or NULL.
metadata_jsonTEXTJSON object of account metadata from the Open directive.

Per-account, per-currency final balances computed from all postings.

ColumnTypeDescription
accountTEXTAccount name (references accounts.name).
currencyTEXTCurrency code.
balanceTEXTCurrent balance as a decimal string (preserves precision).
transaction_countINTEGERNumber of transactions affecting this account+currency.
last_transaction_dateTEXTDate of the most recent transaction (YYYY-MM-DD).

Primary key: (account, currency).

Example — accounts with balances:

SELECT a.name, a.open_date, a.close_date,
ab.currency, ab.balance, ab.transaction_count
FROM accounts a
LEFT JOIN account_balances ab ON a.name = ab.account
ORDER BY a.name

One row per Commodity directive.

ColumnTypeDescription
codeTEXT (PK)Commodity code (e.g., USD, AAPL).
declaration_dateTEXTDate of the commodity directive.
nameTEXTFull name from metadata (e.g., "Apple Inc.").
typeTEXTCommodity type from metadata (e.g., "stock", "currency").
metadata_jsonTEXTJSON object of commodity metadata.

Transaction usage statistics per commodity, computed from postings.

ColumnTypeDescription
codeTEXT (PK)Commodity code.
transaction_countINTEGERNumber of postings using this commodity.
total_volumeTEXTSum of absolute amounts transacted.
first_seenTEXTEarliest transaction date.
last_seenTEXTLatest transaction date.

One row per Price directive. Useful for price history charts.

ColumnTypeDescription
idINTEGER (PK)Auto-incrementing ID.
dateTEXTPrice date (YYYY-MM-DD).
base_currencyTEXTWhat’s being priced (e.g., AAPL).
quote_numberTEXTPrice value as decimal string (e.g., "150.00").
quote_currencyTEXTPrice denomination (e.g., USD).
metadata_jsonTEXTJSON object of price metadata.

Example — price history for a stock:

SELECT date, CAST(quote_number AS REAL) AS price
FROM prices
WHERE base_currency = 'AAPL' AND quote_currency = 'USD'
ORDER BY date

One row per Balance directive. Tracks whether each assertion passed or failed.

ColumnTypeDescription
idINTEGER (PK)Auto-incrementing ID.
dateTEXTAssertion date (YYYY-MM-DD).
accountTEXTAccount being asserted.
amount_numberTEXTExpected balance as decimal string.
amount_currencyTEXTCurrency of the assertion.
toleranceTEXTTolerance value, or NULL.
passedINTEGER1 if assertion passed, 0 if failed.
diff_numberTEXTDifference if failed, NULL if passed.
diff_currencyTEXTCurrency of the difference.
metadata_jsonTEXTJSON metadata.

One row per Pad directive.

ColumnTypeDescription
idINTEGER (PK)Auto-incrementing ID.
dateTEXTPad date (YYYY-MM-DD).
accountTEXTAccount being padded.
source_accountTEXTEquity account to pad from.
metadata_jsonTEXTJSON metadata.

Current investment lot positions with cost basis, computed from Beancount’s booking engine.

ColumnTypeDescription
idINTEGER (PK)Auto-incrementing ID.
accountTEXTAccount holding the position.
units_numberTEXTNumber of shares/units as decimal string.
units_currencyTEXTWhat’s held (e.g., AAPL, BTC).
cost_numberTEXTPer-unit cost basis as decimal string.
cost_currencyTEXTCost denomination (e.g., USD).
acquisition_dateTEXTWhen the lot was opened (YYYY-MM-DD).
labelTEXTLot label (rare, usually NULL).
book_valueTEXTunits_number * cost_number (precomputed).

Example — current holdings with book value:

SELECT account, units_currency AS ticker,
CAST(units_number AS REAL) AS shares,
CAST(cost_number AS REAL) AS cost_per_share,
CAST(book_value AS REAL) AS total_cost
FROM lots
WHERE CAST(units_number AS REAL) > 0
ORDER BY account, units_currency

Beancount parsing errors from the most recent parse.

ColumnTypeDescription
idINTEGER (PK)Auto-incrementing ID.
source_fileTEXTFile path where the error occurred.
line_numberINTEGERLine number of the error.
messageTEXTError message.
entry_jsonTEXTJSON representation of the problematic entry, if available.

Payee/narration to category mappings extracted from transactions. Used for ML-based transaction categorization.

ColumnTypeDescription
idINTEGER (PK)Auto-incrementing ID.
descriptionTEXTPayee + narration text.
categoryTEXTTarget account (e.g., Expenses:Food).

These tables are available but less commonly queried in dashboards:

TableDescription
notesNote directives attached to accounts (date, account, comment).
eventsEvent directives tracking named variables over time (date, type, description).
documentsDocument directives linking files to accounts (date, account, filename).
custom_directivesCustom Beancount directives (date, type, values_json).
stored_queriesNamed BQL queries defined in the ledger (name, query_string).
ledger_optionsBeancount option values (key, value_json).

Accounts with their most recent transaction date:

SELECT a.name, a.open_date, a.close_date,
MAX(ab.last_transaction_date) AS last_activity
FROM accounts a
LEFT JOIN account_balances ab ON a.name = ab.account
GROUP BY a.name
ORDER BY last_activity DESC

Commodities with usage statistics:

SELECT c.code, c.name, c.type,
cu.transaction_count, cu.first_seen, cu.last_seen
FROM commodities c
LEFT JOIN commodity_usage cu ON c.code = cu.code
ORDER BY cu.transaction_count DESC

Failed balance assertions:

SELECT date, account, amount_number, amount_currency,
diff_number, diff_currency
FROM balance_assertions
WHERE passed = 0
ORDER BY date

BQL (Beancount Query Language) is a query language native to Beancount. It queries your ledger entries directly — no SQLite export needed. BQL is SQL-like but has important differences. For the full official specification, see the Beancount Query Language documentation. For a more approachable, hands-on way to learn BQL, check out the interactive beanquery manual by Ev2geny.

ColumnTypeDescription
dateDateTransaction date (YYYY-MM-DD).
yearintegerYear from transaction date.
monthintegerMonth from transaction date (1-12).
dayintegerDay from transaction date (1-31).
flagstringTransaction flag: '*' (cleared) or '!' (pending).
payeestringPayee/merchant name.
narrationstringTransaction description.
tagssetSet of tag strings.
linkssetSet of link strings.
accountstringFull account path (e.g., Expenses:Food:Groceries).
numbernumberPosting amount as a plain number.
currencystringCurrency code (e.g., USD).
amountAmountPosting amount (number with currency).
costAmountCost basis of the posting.
priceAmountPrice conversion amount.
positionPositionThe posting position — use with SUM() for aggregation.
balanceInventoryRunning cumulative balance (auto-calculated, not a stored column).
ColumnTypeDescription
dateDateTransaction date.
yearintegerYear from date.
monthintegerMonth from date (1-12).
dayintegerDay from date (1-31).
flagstringTransaction flag.
payeestringPayee name.
narrationstringDescription.
tagssetTransaction tags.
linkssetTransaction links.
idstringStable hash derived from the transaction contents.
typestringEntry type identifier.
FunctionDescription
COST(position)Get cost basis of a Position or Inventory as an Amount.
UNITS(position)Get units (number and currency) of a Position or Inventory.
YEAR(date)Extract year as integer.
MONTH(date)Extract month as integer (1-12).
DAY(date)Extract day as integer (1-31).
LENGTH(set)Length of a set (e.g., LENGTH(tags)).
PARENT(account)Parent account name (e.g., PARENT("Expenses:Food:Groceries")"Expenses:Food").

These summarize multiple rows and require a GROUP BY clause (or apply to all rows if none is specified).

FunctionDescription
SUM(position)Sum positions into an Inventory. Wrap in COST() for a numeric total.
COUNT(*)Count matching rows.
FIRST(expr)First value encountered.
LAST(expr)Last value encountered.
MIN(expr)Minimum value.
MAX(expr)Maximum value.
OperatorDescriptionExample
=, !=, <, <=, >, >=Comparisonyear = 2026
AND, OR, NOTLogicalaccount ~ 'Expenses' AND year = 2026
~Regex match on stringsaccount ~ 'Expenses:Food'
INSet membership'trip' IN tags
  • No table name needed — write SELECT ... WHERE ... directly. There is no FROM postings.
  • FROM clause filters at the transaction level, WHERE clause filters at the posting level. This is an important distinction:
    • FROM year = 2026 — filters entire transactions by year before expanding to postings.
    • WHERE account ~ 'Expenses' — filters individual postings.
  • Use ~ for pattern matching, not LIKE. The ~ operator uses Python regex syntax.
  • Use SUM(position) for aggregating amounts, not SUM(amount). Wrap in COST() to get a numeric total: COST(SUM(position)). (This is BQL; the SQL-side CAST(amount AS REAL) convention does not apply here.)
  • Use YEAR(date), MONTH(date) for date parts, not strftime().
  • Dates are compared directly without quotes: date >= 2026-01-01.
  • SELECT DISTINCT and SELECT * are supported.
  • GROUP BY can use column positions: GROUP BY 1, 2.
  • ORDER BY and LIMIT work like standard SQL. ORDER BY supports ASC (default) and DESC.
  • HAVING is not implemented in BQL. Filter before aggregation with WHERE instead.
  • NULL = NULL evaluates to TRUE in BQL, unlike standard SQL where it would be NULL.
  • Semicolons at the end of queries are optional.
  • Only SELECT queries are allowed in Finzytrack.
SELECT account, COST(SUM(position))
GROUP BY 1
ORDER BY 2
SELECT YEAR(date) AS y, MONTH(date) AS m, COST(SUM(position))
WHERE account ~ 'Expenses'
GROUP BY 1, 2
ORDER BY 1, 2
SELECT account, COST(SUM(position)) AS total
WHERE account ~ 'Expenses'
GROUP BY 1
ORDER BY 2 DESC
LIMIT 10
SELECT date, narration, position, balance
WHERE account ~ 'Assets:Bank:Checking'
SELECT account, COST(SUM(position))
FROM year = 2026
WHERE account ~ 'Income'
GROUP BY 1
SELECT payee, COST(SUM(position)) AS total
WHERE account ~ 'Expenses'
GROUP BY 1
ORDER BY 2 DESC
LIMIT 20

Total expenses for 2026:

SQL:

SELECT SUM(CAST(amount AS REAL)) AS total
FROM postings
WHERE account_type = 'Expenses' AND year = 2026 AND currency = 'USD'

BQL:

SELECT COST(SUM(position)) AS total
FROM year = 2026
WHERE account ~ 'Expenses'

Top 5 expense accounts:

SQL:

SELECT account, SUM(CAST(amount AS REAL)) AS total
FROM postings
WHERE account_type = 'Expenses' AND currency = 'USD'
GROUP BY account
ORDER BY total DESC
LIMIT 5

BQL:

SELECT account, COST(SUM(position)) AS total
WHERE account ~ 'Expenses'
GROUP BY 1
ORDER BY 2 DESC
LIMIT 5

Use SQL when:

  • Building dashboard recipe widgets (more predictable output format)
  • You need computed columns (e.g., dateFrom, dateTo for click-through links)
  • You need complex date formatting (e.g., month labels)
  • You want explicit control over multi-currency handling with GROUP BY currency
  • You need joins or CTEs

Use BQL when:

  • Running ad-hoc queries in the Query view
  • You want quick account register views with running balances
  • You prefer Beancount’s native query style
  • You don’t need the query output for a dashboard widget (BQL works but SQL is more common in recipes)

Both engines reflect your current ledger state at query time — there’s no staleness difference. The asymmetry is semantic:

  • SQL reads amount as raw units. SUM(CAST(amount AS REAL)) over investment postings gives you “shares × cost-at-purchase” (the recorded transaction amount), not market value and not current cost basis. There’s no automatic lot folding or cost reprojection. For pure-cash accounts (income, expenses, bank accounts) this doesn’t matter — units are the value. For positions held at cost (Assets:Investments:... with {cost} syntax), it matters a lot.
  • BQL knows about positions, lots, and costs. sum(position) keeps each position as a (units, cost) pair you can reproject with COST(...) or VALUE(...). BQL also understands account hierarchy (root_account, leaf_account) without you having to split strings.

Rule of thumb:

  • Cash-only aggregates (spend by category, income by month, payee roll-ups) → SQL, because it’s fast and indexed.
  • Portfolio queries (current holdings, cost basis by account, position-level inspection) → BQL, because it preserves cost semantics.
  • For dashboard recipes that need both worlds, you can split a dashboard across multiple widgets with different dbType values.