Skip to main content

Import a bank CSV into Beancount with bea

Import a bank CSV into your Beancount ledger with bea: map columns, categorize with rules, preview entries, review duplicates, then apply them.

An ordinary bank CSV needs no Python importer. Map its columns with --csv, name the source account with --account, categorize rows with --rules, then preview and apply the entries with bea import.

You need an existing ledger. If you are starting new books, follow the CLI quick start. Keep the original bank export so you can compare it with the preview.

1. Map the CSV columns​

Save this sample as statement.csv, then run the commands below from the same directory:

Date,Payee,Narration,Amount
2026-08-02,Whole Foods,groceries,-20.00
2026-08-03,Shell,gas,-40.00
2026-08-04,Unknown Shop,mystery,-9.99

Amounts use the bank sign convention: spending is negative and a deposit is positive. The currency defaults to the ledger's operating currency, so this file needs no currency column. Put a bank description column in narration and keep payee for the merchant.

Create the ledger and open the fuel subaccount used below:

bea --no-input init books --currency USD --date 2026-08-01 \
  --opening-balance "Assets:Checking 1000"
bea --file books/main.bean add open --date 2026-08-01 --account Expenses:Transport:Fuel -c USD

The template already opens Expenses:Groceries and the other common accounts. It does not open Expenses:Transport:Fuel, so the second command opens it before the import. Global options such as --file go before the subcommand.

2. Preview the entries​

Save these categorization rules as rules.toml, then preview:

cat > rules.toml <<'EOF'
[[rule]]
match = "whole foods|trader joe|corner market"
account = "Expenses:Groceries"
 
[[rule]]
match = "shell|chevron|exxon"
account = "Expenses:Transport:Fuel"
EOF
bea --file books/main.bean import statement.csv --csv date=Date,amount=Amount,payee=Payee,narration=Narration --account Assets:Checking --rules rules.toml

Rules match the payee first, then the narration, ignoring case. The first matching rule wins. Rows no rule matches post to Expenses:Uncategorized with flag ! for later review. The product IMPORTING guide documents the full mapping reference, including the debit and credit pair, the category column, and --csv auto header reading.

Nothing is written to the ledger yet. The preview reports 3 ready, 0 exact duplicates, 0 possible duplicates and exits 0. Its RULE column names the winning pattern per row, or unmatched for the Unknown Shop row. Review the dates, payees, signed source amounts, destination accounts, duplicate matches, and proposed file diff. Fix an incorrect rule or category, then preview again. Open any missing accounts before applying the import: a rule naming an account the ledger does not open fails validation.

3. Apply the reviewed entries​

bea --file books/main.bean import statement.csv --apply
bea --file books/main.bean check
bea --file books/main.bean list transaction --flag '!'
bea --file books/main.bean query "SELECT account, sum(position) WHERE account = 'Assets:Checking' GROUP BY account"

The column mapping is remembered per ledger, header row, and source account, so --apply re-runs with no flags and reports using the remembered column mapping. It recomputes the preview against the current files, validates the complete candidate ledger before writing, and writes 3 entries. bea check reports no errors. The ! queue lists the one unmatched row: Unknown Shop with mystery at -9.99 USD. A passing check only proves the ledger balances and validates. It says nothing about whether that row belongs in Expenses:Uncategorized, so recategorize it deliberately in your ledger. Checking ends at 930.01 USD: the 1,000 USD opening balance minus 69.99 USD of spending.

4. Repeat imports add nothing​

bea --file books/main.bean import statement.csv --apply

The preview reports 0 ready, 3 exact duplicates, and the run writes 0 entries with exit 0. Every written row carries import-id metadata with a content hash, so the identical file matches every row. Retain that metadata when editing imported entries. Importing adds entries; it does not update or delete an existing transaction. Make corrections deliberately in your ledger and run bea check afterward. Bulk JSON entry with bea add transactions has no duplicate detection.

5. Resolve possible duplicates​

A later download can repeat a row with different narration or bank IDs. Date, normalized payee, and signed source amount still mark it as a possible match:

Preview statusMeaningWhat to do
newNo duplicate evidence foundCheck the amounts and categories
duplicateA stable ID and transaction details match, or an identical nontransaction directive existsAlready skipped
possible_duplicateThe date, normalized payee, and signed source amount/currency matchCompare the preview with the existing entry
conflictA stable ID matches different transaction detailsResolve the ID or data discrepancy, then preview again

A different bank ID does not rule out a duplicate. Banks can change IDs on later downloads. Two real purchases can also share a date, payee, and amount, so a possible match is evidence and not proof. Bea does not guess with an AI model and never categorizes for you beyond your rules.

The default --duplicates review refuses to apply unresolved matches. In a verification run, a second file repeating the 2026-08-02 Whole Foods -20.00 USD row under a different narration previewed as 1 possible duplicate, and --apply exited 4 with nothing written. After reviewing every possible match, choose one of these alternatives:

bea --file books/main.bean import statement.csv --apply --duplicates skip
bea --file books/main.bean import statement.csv --apply --duplicates include

Choose include to preserve legitimate repeated purchases. The decision applies to all possible matches in that invocation. Exact duplicates remain skipped. ID conflicts still block the write. --no-input and --yes do not bypass that review. An intentional decision to skip every row exits 0 with no ledger additions.

6. Use a Python importer for other formats​

For formats the column mapping cannot express, such as OFX or QIF or a CSV with an unusual layout, bea import calls a configured importer using the current Beangulp interface: identify(filepath), account(filepath), and extract(filepath, existing). The importer owns bank-specific parsing and categorization. It must supply explicit amounts on source-account postings so duplicate matching uses actual bank amounts. A Python importer remains the advanced path for these formats. For a bank's native CSV, try --csv first.

For a first practice run, save the example categorized CSV configuration as importers.py beside your root ledger. It uses only Beancount and Python's standard library, so it works with the Homebrew installation. Its sample bank.csv uses a signed checking-account amount: a -5.25 USD dining expense and a 1,000 USD salary deposit. The sample configuration expects exactly its documented columns. Only execute Python configurations you trust.

bea --file books/main.bean import bank.csv --config importers.py
bea --file books/main.bean import bank.csv --config importers.py --importer categorized-checking
bea --file books/main.bean import bank.csv --config importers.py --apply

Your importers.py configuration exports CONFIG = [importer, ...]. If several importers recognize the file, select one by name. An unknown name lists the configured names. A known importer that does not recognize the file reports that separately.

The CLI remembers the configuration path for this root ledger. Future runs choose the explicit --config, then the remembered path, then importers.py beside the root. The output names the path and where it came from.

--apply recomputes the preview against the current files. It validates the complete candidate ledger before writing. A validation failure leaves the original ledger unchanged and exits 1. A concurrent ledger change exits 4; inspect the change and run a fresh preview before retrying.

Keep imports repeatable​

By default, duplicate matching checks bank_id, fitid, transaction_id, and imported_id metadata within the importer's source account. Use repeated --id-key KEY options to replace that set.

A row with a stable bank ID is written with import-id metadata naming its kind, such as a bank: or ofx: prefix. A row without one is written with a csv:sha256: content hash over its date, amount, description, and account, so re-importing the same file skips every row. Entries written before this convention may still carry bea_import_id metadata, and those still match on re-import. Possible matches are checked against existing transactions and accepted rows in the same batch.

Payees, narrations, and string metadata replace line breaks with spaces before preview and writing. Quotes and backslashes keep their contents. Imported merchant text therefore stays readable on a single ledger line.

Write to an included file​

Keep --file pointed at the root and select the destination with --into:

bea --file books/main.bean import statement.csv --into 2026.bean
bea --file books/main.bean import statement.csv --into 2026.bean --apply

2026.bean must already exist and be included by the root. Its path is relative to the root directory. The export path remains relative to your working directory. The preview identifies the file that will change.

Use imports in a script​

bea --file books/main.bean --json --no-input import statement.csv --apply --duplicates skip

Choose skip only when that is your intended policy for possible matches. JSON returns the preview and write count inside data. Refused applications put the preview in error.result on stderr, with written: 0. Always check the exit status. See the JSON and exit-code reference before scheduling unattended imports.

Troubleshoot an importer​

Importer configurations run in the managed engine. If a configuration imports Beangulp, install the system libmagic library and enable Beangulp there once:

bea engine enable beangulp
bea --file books/main.bean import bank.ofx --config importers.py
bea --debug --file books/main.bean import bank.csv --config importers.py

bea engine status reports the enabled features. Installing a bank importer alongside the bea frontend does not make it available inside the engine. A configuration that imports additional packages needs those dependencies in the engine; enabling Beangulp alone does not install them. Use the CSV mapper or the converters below when those importer dependencies are unavailable.

For an importer exception, put --debug before the command to show its traceback. Importer output is captured in importer_output so it does not corrupt JSON. In JSON debug mode, the traceback is error.traceback.

For a one-time conversion without a Python importer, try the CSV converter or OFX and QIF converter. Review the generated entries before adding them to your books.

Source: https://beancount.io/docs/Solutions/import-bank-exports-cli