Introduction
What is saldo?
saldo is a domain-specific programming language (DSL) and command-line
interface (CLI) for creating financial forecasts.
How a simulation runs
saldo budget.saldo --from 2026-01-01 --to 2026-12-31
saldo simulates one day at a time and reports every day from --from to
--to, inclusive.
Warm-up
If an account opens before --from, the simulation starts on the earliest
opening date instead. Entries fire normally during this warm-up, but their
transactions aren’t printed. The opening balances you see are the balances on
--from, after the warm-up.
Accounts without an opening balance start at zero on the first simulated day.
Period totals (.ytd, .qtd, .mtd) also start at zero on the first
simulated day. If that day falls partway through a period and the leg’s entry
would already have fired earlier in it, saldo warns that the total is missing
amounts. To fix it, simulate from the start of the period, or open an account
by then so the warm-up covers it.
Each day
Every simulated day runs these steps in order:
- Period totals reset if the day starts a new month, quarter or year.
- Params are evaluated. A param that reads an account sees the balance before any entries fire that day.
- Accounts whose opening date is today get their opening balance, in declaration order. If any account opened, params are evaluated again so they can see it.
- Entries whose schedule matches today fire, in declaration order. A later entry sees the balances left by earlier ones. Within one entry, every posting is evaluated against the balances from before that entry fired.
- Assertions whose schedule matches today are checked against the balances after all entries fired.
- Named legs from today’s entries are added to their period totals. So a
.ytdread during an entry doesn’t include that day’s amount.
The first failing assertion or evaluation error stops the simulation.
Output
The ledger output (the default) starts with an opening-balances transaction
for the balances on --from, balanced against Equity:OpeningBalances. An
account that opens later gets its own opening-balances transaction on its
opening date. After that comes one transaction per entry firing, skipping
firings whose postings all come to zero.
The CSV output (--format csv) has one row per day with every account’s
balance at the end of that day, in the order the accounts are declared.
Accounts
An account is a named balance that the simulator tracks over time. Every account that appears in an entry or assertion must be declared.
Syntax
account <path> [= <expression> @ <date>]
The path is one or more identifiers joined by colons:
account Assets:Cash
account Assets:Retirement:Jim
account Liabilities:Loan
account Income:Gross:Salary:Jim
account Expenses:Rent
Opening balance
Without = … @ …, an account starts at zero and is available for the entire
simulation. Supply an initial value and a date to give the account a known
opening balance on a specific day:
account Assets:Cash = 12_500 @ 2025-01-01
account Liabilities:Loan = -450_000 @ 2025-01-01
account Assets:Retirement:Seb = 87_340.22 @ 2024-07-01
The = value and @ date must always appear together — one without the other
is a parse error.
The expression is evaluated on the opening date. It can reference params and accounts that are already open, but not accounts that open later or leg aggregations.
Referencing accounts before their opening date
Referencing an account (in an entry posting or an expression) before its
@ date is a runtime error:
account Assets:Cash = 1000 @ 2025-06-01
// This entry fires in January — before Assets:Cash opens — and will error:
entry monthly "Paycheck" {
Assets:Cash = 500
Income:Salary
}
To avoid the error, ensure entry schedules start no earlier than the latest opening date of any account they reference, or set the opening date early enough to cover the simulation range.
Simulation start after opening date
If your simulation start is later than an account’s opening date, saldo automatically warms up the simulation from the opening date. All entries that fire during the warm-up period are processed normally — only their output (ledger transactions, CSV rows) is suppressed. The opening-balances entry in ledger output reflects the actual balance at your simulation start, after the warm-up.
account Assets:Cash = 1000 @ 2024-01-01
entry monthly "Paycheck" {
Assets:Cash = 500
Income:Salary
}
// Running from 2025-01-01: Assets:Cash opens at 1000 on 2024-01-01, then
// 12 monthly paycheck entries fire during warm-up, so the opening balance
// shown in the ledger output is 7000 (1000 + 12 × 500).
Naming conventions
saldo does not enforce any particular hierarchy, but the double-entry conventions used throughout these docs are:
| Root | Holds |
|---|---|
Assets:… | Things you own (cash, retirement, savings) |
Liabilities:… | Things you owe (loans, accrued interest) |
Income:… | Sources of income — carried as negative by convention |
Expenses:… | Spending categories |
Income accounts are negative because every paycheck entry credits income (negative posting) and debits assets (positive posting), keeping the net of all postings at zero.
Using accounts in expressions
Reference an account by its full path in any expression:
// Current balance of a liability account
Liabilities:Loan * interest_rate / 365
// Assert cash never goes below a threshold
assert that Assets:Cash >= 10_000
// Compute daily interest on the outstanding balance
entry daily "Interest accrual" {
Liabilities:AccruedInterest = Liabilities:Loan * interest_rate / 365
Expenses:Interest
}
An account reference in an entry reads the current balance. Entries that fire on the same day run in declaration order, so a later entry sees the postings of earlier ones. Within a single entry, every posting is evaluated against the balances from before that entry fired, so one posting never sees another posting from the same entry.
Params are evaluated at the start of each day, so a param that reads an account sees the balance before any entries fire that day.
Declaration order
Accounts appear in the CSV output columns in the order they are declared. Declare them in the order you want to read them.
Complete example
account Assets:Cash = 12_500 @ 2025-01-01
account Assets:Retirement:Jim = 45_000 @ 2025-01-01
account Liabilities:Loan = -320_000 @ 2025-01-01
account Income:Gross:Salary:Jim
account Expenses:Rent
account Expenses:Interest
param jim_salary = 130_000 per year
param interest_rate = 6.5% per year
entry monthly "Jim's paycheck" {
Assets:Cash = jim_salary
Income:Gross:Salary:Jim
}
entry monthly "Rent" {
Expenses:Rent = 3_915
Assets:Cash
}
entry daily "Loan interest" {
Liabilities:Loan = Liabilities:Loan * interest_rate
Expenses:Interest
}
assert that Assets:Cash >= 0
Schedules
A schedule determines when an entry fires or an assertion is checked. Schedules appear inline on entries and assertions, or they can be named and reused.
Named schedules
schedule <name> = <schedule-expression>
schedule semi_monthly = every month on the 15th, last day
schedule every_two_weeks = every second friday from 2026-01-02
A named schedule is referenced by its identifier wherever a schedule is expected.
Adverbial shortcuts
The simplest schedules are single-word adverbs. Each has a sensible default when no further detail is given.
| Keyword | Fires on |
|---|---|
daily | Every day |
weekly | Every Sunday |
monthly | Last day of every month |
quarterly | Mar 31, Jun 30, Sep 30, Dec 31 |
yearly / annually | Dec 31 |
Each adverb accepts an optional on clause to override the default:
weekly on friday # every Friday
weekly on monday and wednesday # Mon and Wed each week
monthly on the 1st # first of every month
monthly on the 15th, last day # 15th and last day
yearly on jan 1st # New Year's Day
yearly on may first, jul last # May 1 and July 31 each year
The every form
The every keyword gives you full control.
every <period> [from <date>]
every <n> <period> from <date>
Periods
day — fires every day (or every n days from a start date):
every day
every 3 days from 2026-01-01
week [on <days>] — fires every week on the given day(s). Without on,
defaults to Sunday:
every week
every week on thursday
every week on weekend and friday
<weekday> — shorthand for every week on <weekday>:
every friday
every monday
every weekday
month [on the <occurrences>] — fires every month. Without on the,
defaults to the last day of the month:
every month
every month on the 1st
every month on the last day
every month on the 2nd monday
every month on the 3rd thursday, 15th
<month-name> [<ordinal>] — fires once a year in the named month. Without
an ordinal, defaults to the last day of that month:
every january
every january 1st
every december last
every aug 15th
quarter — fires at the end of each quarter:
every quarter
year [on <month> <ordinal>, ...] — fires once a year. Without on,
defaults to Dec 31:
every year
every year on april 15th
every year on jan 31, jul 31
Skipping occurrences
Prefix the period with a count to fire every nth occurrence. A from date
is required to anchor the sequence:
every 2 weeks from 2026-01-05 # biweekly starting Jan 5
every second friday from 2026-01-02 # alternate Fridays
every 3 months from 2026-01-01 # quarterly with custom anchor
every 2 years from 2026-01-01 # biennially
Ordinal words (second, third, fourth, …, tenth) and numeric suffixes
(2nd, 3rd, 4th, …) are both accepted.
every <n> weeks on <days> counts calendar weeks (Monday to Sunday) from the
week containing the from date, so all the listed days of a week fire
together. every <n> <weekday> counts occurrences of that day instead,
starting with the first one on or after the from date.
Literal date lists
A comma- or and-separated list of ISO dates fires on exactly those days:
2026-04-15
2026-04-15 and 2026-10-15
2026-01-01, 2026-07-04, 2026-12-25
Default behaviors summary
Without an on clause, every period fires on its last day. Weeks run
Monday to Sunday.
| Form | Default firing day |
|---|---|
weekly | Sunday |
every week | Sunday |
monthly | Last day of month |
every month | Last day of month |
quarterly | Quarter-end (Mar 31 / Jun 30 / Sep 30 / Dec 31) |
yearly / every year | Dec 31 |
every <month-name> | Last day of that month |
Params
A param is a named numeric value that can change over time. Params let you express things like salary, contribution limits, or interest rates in one place and reference them throughout your entries and assertions.
Constant params
param <name> = <expression>
param interest_rate = 5%
param retirement_rate = 0.16
param max_401k = 24_500 per year
A % after a value divides it by 100, so 5% is 0.05. per year makes
the value a rate (see Rates).
The expression is re-evaluated at the start of each simulated day. For expressions built from numbers and other constant params, the value never changes. A param that reads an account balance follows that balance as it changes.
Time-varying params
param <name> {
from <date> [to <date>] = <expression>
from <date> [to <date>] = <expression>
...
}
param salary {
from 2025-12-31 to 2026-04-01 = 115_000 per year
from 2026-04-01 = 130_000 per year
}
Each interval specifies a from date (inclusive) and an optional to
date (exclusive). The simulator uses whichever interval covers the current
day. Intervals must not overlap. An interval without a to clause extends
indefinitely.
On a day that no interval covers (before the first interval, in a gap
between intervals, or after the last one ends), the param has no value.
Using it on such a day is an error, so add an interval with the value you
want, for example = 0, to cover those days.
A more complete example:
param beth_salary {
from 2026-01-01 to 2027-01-01 = 160_000 per year
from 2027-01-01 to 2028-01-01 = 190_000 per year
from 2028-01-01 to 2029-01-01 = 225_000 per year
from 2029-01-01 to 2030-01-01 = 255_000 per year
}
Rates
per day, per week, per month, per quarter or per year after a
value makes it a rate: an amount per period, like a salary or a yearly
contribution limit. Write a rate the way you’d say it, and let entries work
out how much of it each firing posts (see Entries):
param salary = 120_000 per year
param rent = 3_000 per month
param interest = 5% per year
per binds tighter than any other operator, so salary - 500 per month
subtracts 500 a month, and (a + b) per year needs its parentheses.
saldo checks how rates combine before it simulates anything:
| Expression | Is |
|---|---|
salary * 0.16, salary / 2 | a rate per year |
salary + bonus (both per year) | a rate per year |
salary + 5_000 | a rate per year: plain numbers take on the unit of what they’re combined with |
Liabilities:Loan * interest | a rate per year |
rent per year | a rate per year: 36,000 |
max_401k - contribution.ytd | an amount: what’s left of this year’s limit |
salary - Assets:Cash | an error, except in a posting |
salary + rent | an error, except in a posting |
A rate and a total over the same period, like a yearly limit and a .ytd
total, can be added, subtracted and compared: the rate counts in full. Other
amounts, like account balances, only mix with rates in an entry’s postings,
where a rate means the firing’s share of it.
A param takes on the kind of its value, so param gross = salary + bonus is
a rate too. per converts rates between months, quarters and years, or
between days and weeks, but not from weeks or days to months or years, since
those aren’t a fixed number of weeks or days.
Using params in expressions
Reference a param by name in any expression:
jim_salary * 0.16
Liabilities:Loan * interest_rate
min(salary * rate, max_401k - retirement_contribution.ytd)
A time-varying param automatically returns the right value for the current date, so you never need to branch on time in your expressions.
Aggregations
Named legs on entries (see Entries) accumulate into period-to-date buckets that you can read in any expression:
| Syntax | Meaning |
|---|---|
<leg>.ytd | Year-to-date total of the named leg |
<leg>.qtd | Quarter-to-date total |
<leg>.mtd | Month-to-date total |
<flow>.<leg>.ytd | Year-to-date total, scoped to a specific flow alias |
These reset automatically at the start of each year, quarter, or month.
min(salary * 0.16, max_401k - retirement_contribution.ytd)
Entries
An entry (also called a flow) is a transaction that fires on a schedule. Each time it fires, it moves money between accounts according to a list of postings. All postings in one firing must balance to zero.
Syntax
entry <schedule> "<label>" {
<account> [= <amount>] [as <leg>]
...
} [as <alias>]
Postings
Each line inside the braces is a posting: an account and an optional amount.
Fixed amount
Assets:Cash = 5_000
Expenses:Rent = 3_915.30
The account balance is increased by the given amount. Use a negative expression to decrease a balance.
Amounts are rounded to cents using round-half-to-even (0.125 becomes
0.12), the same rule hledger and beancount use. If every posting in a
firing comes to zero, no transaction is written for it.
Auto-balance
Omit = on exactly one posting per entry. saldo calculates the amount that
makes all postings sum to zero:
entry monthly "Jim's paycheck" {
Assets:Retirement:Jim = 1_500
Assets:Cash = 7_500
Income:Gross:Salary:Jim // auto-balanced: receives -9_000
}
Because income accounts carry a negative balance by convention, the auto-balanced posting receives the negation of the net inflow.
Clearing a balance (all)
Use = all to move the entire current balance of an account:
entry monthly "Loan payment" {
Liabilities:AccruedInterest = all // clears whatever has accrued
Liabilities:Loan = 2_000
Assets:Cash
}
Named legs
Append as <name> to a posting to give it a leg name. The leg accumulates
into period-to-date totals that can be read in later expressions within the
same simulation day:
param jim_salary = 120_000 per year
param max_401k = 24_500 per year
entry semi_monthly "Jim's paycheck" {
Assets:Retirement:Jim = min(jim_salary * 0.16,
max_401k - retirement_contribution.ytd) as retirement_contribution
Assets:Cash = jim_salary - retirement_contribution
Income:Gross:Salary:Jim as gross_income
} as jim_paycheck
retirement_contribution.ytd is the running year-to-date sum of every
retirement_contribution leg across all firings so far this year. Once a
leg name is established, you can reference it in the same entry on
subsequent posting lines (as retirement_contribution above, without .ytd),
which gives you the value from the current firing.
Available aggregation suffixes:
| Suffix | Resets |
|---|---|
.ytd | January 1 |
.qtd | First day of each quarter |
.mtd | First day of each month |
Flow alias
The optional as <alias> at the end of the block gives the flow a name for
use in scoped aggregations:
} as jim_paycheck
Reference a leg scoped to this flow with <alias>.<leg>.ytd:
assert that jim_paycheck.retirement_contribution.ytd <= 24_500
Without an alias, a leg can only be referenced from inside its own entry
(retirement_contribution.ytd). Add an alias to read it from other entries,
params, or assertions.
Rates
A posting whose amount is a rate, like 24_500 per year (see
Params), posts each firing’s share of it:
param salary = 120_000 per year
param max_401k = 24_500 per year
entry every month on the 15th and last day "Paycheck" {
Assets:Retirement = max_401k as contribution
Assets:Cash = salary - contribution
Income:Salary
}
The days in each calendar year that fit the entry’s schedule split the
year’s amount equally, rounded so that they add up to it exactly. Here
that’s 24 contributions of 1020.83 or 1020.84, which come to 24,500.00. An
every second friday schedule has 26 paydays in some years and 27 in
others, and each year still comes to 24,500.00.
In a posting, a rate used with an amount, like salary - contribution,
means the firing’s share of it, and so do rates that min, max and if
choose between. A rate added to, subtracted from or compared with a total
over the same period counts in full, so this contributes 16% of each
paycheck until the year’s limit is reached:
entry every month on the 15th and last day "Paycheck" {
Assets:Retirement = min(salary * 0.16, max_401k - contribution.ytd) as contribution
Assets:Cash = salary - contribution
Income:Salary
}
In detail:
- Periods are calendar periods of the rate: years, quarters,
months, weeks (Monday to Sunday) or days. A daily entry posts 1/365 of a
yearly rate, or 1/366 in a leap year, so interest at
Liabilities:Loan * rateaccrues by the actual number of days. - Changes apply from the next firing. Each firing uses the rate’s value on its own day, so a raise on April 1 shows up in the next paycheck.
- The schedule’s pattern decides the split, not its
fromdate. An entryevery month from 2026-07-01posts a twelfth of a yearly rate each month, so half of it in 2026. Firings before the simulation starts count too, so you see the same paychecks whatever--fromyou choose. To post the whole amount over the firings that are left, usefill. - A period without a firing rolls into the next one, back to the
schedule’s
fromdate. Aquarterlyentry posts three months of a rate per month, anevery second fridayentry posts two weeks of a rate per week, and an entryevery month from 2026-03-17first posts March 17 to 31 of a rate per day.
The last rule gives you the other common way of paying a yearly salary biweekly: the same amount every payday, so that a year with 27 paydays pays more. Declare the salary per week, and start the schedule on an earlier payday so that the first one in your simulation covers two weeks:
param salary = (130_000 / 52) per week
It also charges interest by the actual days in each period. This loan starts on January 10 and is paid at the end of each month, with interest on each period’s days at 1/365 of the yearly rate:
entry every month from 2026-01-11 "Loan payment" {
Expenses:Interest = (-Liabilities:Loan * 6% / 365) per day as interest
Liabilities:Loan = min(300, interest - Liabilities:Loan) - interest
Assets:Cash
}
The first payment covers January 11 to 31, and min makes the last one
smaller, paying off what’s left.
Filling a target
A rate spreads evenly, like a salary. Some amounts are targets instead: you
want a year’s 401(k) contributions to reach the limit, however many
paychecks are left. fill posts what’s left of the period’s amount,
divided by the firings left in the period:
param salary = 150_000 per year
param max_401k = 24_500 per year
entry every second friday from 2026-07-10 "New job" {
Assets:Retirement = fill(max_401k) as contribution
Assets:Cash = salary - contribution
Income:Salary
}
The job starts in July, so its 13 paydays in 2026 contribute 24,500 between them, while the salary, which is spread, pays half a year. From 2027 there are 26 paydays, and each contributes a 26th.
What’s left is the amount minus what the posting has already posted this
period, so if an earlier firing posts less, later ones make up the
difference. min(fill(max_401k), cap) contributes as much as cap allows
and catches up when it can. To count contributions from elsewhere, subtract
them from the target, for example fill(max_401k - old_job.contribution.ytd).
Don’t subtract the posting’s own total: fill already does.
The period comes from the target: a rate’s unit, or a total’s period, like
the year of a .ytd. fill can only be the amount a posting posts, or
what min, max or if choose for it. Like a .ytd total, it only knows
what was posted since the simulation started, so saldo warns if it starts
partway through a period after the entry would have fired.
Complete example
param max_401k = 24_500 per year
param jim_salary {
from 2025-12-31 to 2026-04-01 = 115_000 per year
from 2026-04-01 = 130_000 per year
}
param retirement_rate = 16%
param interest_rate = 5% per year
entry monthly "Jim's paycheck" {
Assets:Retirement:Jim = min(jim_salary * retirement_rate,
max_401k - retirement_contribution.ytd) as retirement_contribution
Assets:Cash = jim_salary - retirement_contribution
Income:Gross:Salary:Jim
} as jim_paycheck
entry daily "Interest accrual" {
Liabilities:AccruedInterest = Liabilities:Loan * interest_rate
Expenses:Interest
}
Asserts
An assertion is a condition that must hold true on certain days. If the condition evaluates to false the simulation stops and reports the failure. Assertions are how you express financial constraints and goals.
Syntax
assert [<schedule>] that <boolean-expression>
Without a schedule, an assertion is checked every day of the simulation. With a schedule, it is checked only on days that match.
Daily assertions
assert that Assets:Cash >= 0
assert that jim_paycheck.retirement_contribution.ytd <= 24_500
These fire every day. The first ensures the cash account never goes negative. The second ensures a named leg never exceeds a limit.
Scheduled assertions
Any schedule expression can precede the condition:
assert quarterly that Assets:Retirement >= 0
assert monthly on the last day that Assets:Cash >= 10_000
assert every friday that Liabilities:AccruedInterest >= 0
Date-specific assertions
A single date (or a list of dates) acts as a schedule:
assert 2026-12-31 that Assets:Retirement:Seb == 24_500
assert 2026-12-31 that Assets:Retirement:Jess == 24_500
Expressions
Assertion expressions support the same operators as entry amounts:
| Operator | Meaning |
|---|---|
<, <= | Less than, at most |
>, >= | Greater than, at least |
==, != | Equal, not equal |
and, or, not | Combine conditions (not binds tightest, then and, then or) |
if … then … else … | Conditional (both then and else are required) |
min(), max() | Built-in functions |
Account references, param names, and aggregation suffixes (.ytd, .qtd,
.mtd) all work inside assertion expressions.
Comparisons can’t be chained: write 0 <= x and x <= 100, not
0 <= x <= 100. The right side of and and or is only evaluated when
needed.
Examples
// Cash never goes negative
assert that Assets:Cash >= 0
// 401(k) contribution limit not breached
assert that jim_paycheck.retirement_contribution.ytd <= max_401k
// Target retirement balance hit by a specific date
assert 2026-12-31 that Assets:Retirement:Beth == 24_500
// Cash stays within a band
assert that Assets:Cash >= 1_000 and Assets:Cash <= 50_000
// Sanity-check every quarter
assert quarterly that Assets:Retirement:Seb >= 0
Functions
User-defined functions let you name and reuse a computation across params, entries, and assertions. They are pure — they take explicit arguments and return a value; they cannot read global params or account balances.
Syntax
fn <name>(<param>, ...) {
[let <name> = <expression>;]
...
[return] <expression>
}
The final expression is the return value. The return keyword is optional.
Defining a function
fn double(x) { x * 2 }
fn net(gross, rate) {
let tax = gross * rate;
gross - tax
}
Local bindings introduced with let are available for the rest of the body.
Calling a function
A function call looks like any other expression and can appear anywhere an expression is valid: in a param definition, an entry amount, or an assertion.
param gross = double(salary)
entry monthly "pay" {
Assets:Cash = net(gross / 12, 0.3)
Income:Salary
}
Calling built-ins
User-defined functions can call built-in functions such as min and max.
fn positive(x) { max(x, 0) }
Calling other functions
A function can call any other function defined in the file, as long as there is no cycle.
fn double(x) { x * 2 }
fn quad(x) { double(double(x)) }
Conditional expressions
if … then … else … works inside function bodies.
fn bonus(salary, target) {
return if target > 0 then salary * 0.1 else 0;
}
Time-varying inputs
Functions have no special awareness of time, but because params are evaluated as of the current simulation date before being passed in, a function automatically produces a different result on different days when its arguments are time-varying.
fn double(x) { x * 2 }
param salary {
from 2025-01-01 to 2025-07-01 = 100
from 2025-07-01 = 200
}
param doubled = double(salary)
During January doubled is 200; from July onwards it is 400. The
function definition itself never changes — only the value of salary at
the point it is called does.
Restrictions
- No global params. Function bodies can only reference their own
parameters and local
letbindings. Referencing a global param or account inside a function body is an error. - No recursion. A function cannot call itself, directly or through a cycle of calls.
- No duplicate names. Each function name must be unique within the file.
- Arity is checked. Calling a function with the wrong number of arguments is an error.
Full example
fn net(gross, rate) {
let tax = gross * rate;
gross - tax
}
param salary {
from 2025-01-01 to 2026-01-01 = 120_000 per year
from 2026-01-01 = 140_000 per year
}
entry monthly "paycheck" {
Assets:Cash = net(salary, 0.28)
Expenses:Tax = salary * 0.28
Income:Salary
}
Rates pass through functions: salary is per year, so net(salary, 0.28)
is too, and the entry posts a twelfth of it each month (see
Rates).
Imports
A model can be split across files: salary in one, loans in another, and a
main file that brings them together. import reads another file into the
model.
Syntax
import "<path>"
The path is relative to the directory of the file the import is in, or absolute. Imports go between declarations, anywhere in a file.
Example
budget.saldo, the file you run:
account Assets:Cash = 5_000 @ 2026-01-01
import "salary.saldo"
import "loans/car.saldo"
assert that Assets:Cash >= 0
salary.saldo:
account Income:Salary
param salary = 95_000 per year
entry monthly on the 15th and last day "Paycheck" {
Assets:Cash = salary
Income:Salary
}
loans/car.saldo:
account Liabilities:Loans:Car = -30_000 @ 2026-01-01
account Expenses:Interest:Car
param car_loan_rate = 5% per year
entry monthly on the last day "Car loan payment" {
Expenses:Interest:Car = -Liabilities:Loans:Car * car_loan_rate
Liabilities:Loans:Car = 600
Assets:Cash
}
saldo budget.saldo --from 2026-01-01 --to 2026-12-31
One model, many files
All the files make up one model, with one set of names. A file can use any
account, param, schedule, function or entry alias declared in any other file,
whether it imports that file or not: the car loan pays from Assets:Cash,
which budget.saldo declares. Names still have to be unique across files, so
declaring Assets:Cash in two files is an error.
Each file is read once, no matter how many files import it. Two files can import a shared file of accounts, and files can import each other.
Order
An import works as if the file’s declarations were written where it’s first
imported. That order matters in two places: entries that fire on the same day
fire in declaration order, and CSV columns follow the order accounts are
declared in. Above, a paycheck on the last day of the month comes before that
day’s car loan payment, because salary.saldo is imported first.