Essential Beancount plugins and import automation for 2025

The State of Beancount Plugins in 2025

The Beancount plugin ecosystem has matured significantly over the past few years. Martin Blais (Beancount’s creator) has explicitly stated that more functionality is moving to plugins rather than being built into the core, making the plugin system increasingly important for power users.

Let me walk through the essential plugins, the import automation landscape, and how to write your own custom plugins.

Essential Plugins You Should Know

1. beancount.plugins.check.check_zero_sum

What it does: Ensures specific accounts sum to zero over a period (perfect for budgets and tracking categories).

Use case: Tracking reimbursable expenses

plugin "beancount.plugins.check.check_zero_sum" "Expenses:Reimbursable"

2024-01-15 * "Business lunch (will be reimbursed)"
  Expenses:Reimbursable:Meals   50.00 USD
  Assets:Cash                  -50.00 USD

2024-01-30 * "Reimbursement received"
  Assets:Bank:Checking          50.00 USD
  Expenses:Reimbursable:Meals  -50.00 USD

; At period end, Expenses:Reimbursable should sum to zero
; If it doesn't, you forgot to get reimbursed!

2. beancount_reds_plugins.capital_gains_classifier

What it does: Automatically classifies capital gains as short-term or long-term for tax purposes.

Installation: pip install beancount-reds-plugins

plugin "beancount_reds_plugins.capital_gains_classifier" "{
  'long_term_account': 'Income:CapitalGains:LongTerm',
  'short_term_account': 'Income:CapitalGains:ShortTerm'
}"

; When you sell stock, it automatically routes gains to the right account
; based on holding period (>365 days = long-term)

3. beancount_interpolate

What it does: Splits recurring transactions across time periods automatically.

Use case: Annual subscriptions that should be allocated monthly

plugin "beancount_interpolate.recur"

2024-01-01 * "Annual software subscription" ^recur-monthly-12
  Expenses:Software:Subscription   1200.00 USD
  Assets:Cash                     -1200.00 USD

; Plugin automatically creates 12 monthly transactions of $100 each

4. beancount_portfolio_allocation

What it does: Validates that your investment portfolio matches target allocations.

GitHub: GitHub - ghislainbourgeois/beancount_portfolio_allocation: Reports on portfolio asset allocations in beancount. Useful for risk analysis and for rebalancing purposes.

plugin "beancount_portfolio_allocation" "{
  'target': {
    'Stocks': 0.70,
    'Bonds': 0.20,
    'Cash': 0.10
  },
  'tolerance': 0.05
}"

; Warns you when allocations drift beyond 5% tolerance

5. beancount-plugins: gains_minimizer

What it does: Automatically selects which tax lots to sell to minimize capital gains.

Use case: Tax-loss harvesting and optimizing sales

plugin "beancount.plugins.unrealized" "Unrealized"

2024-06-01 * "Sell 10 shares (plugin picks best lots)"
  Assets:Investments:AAPL   -10 AAPL {} @ 180.00 USD
  Assets:Cash                1800.00 USD
  Income:Gains

; Plugin automatically fills in {} with optimal lot selection

The Import Automation Landscape

beancount-import: The Semi-Automated Approach

GitHub: GitHub - jbms/beancount-import: Web UI for semi-automatically importing external data into beancount

This is the most mature import solution. It’s semi-automated, meaning it presents candidates for you to review:

pip install beancount-import
python -m beancount_import.webserver   --data_dir ~/finances/import-data   --ledger ~/finances/main.beancount

Key features:

  • Web UI for reviewing imported transactions
  • Fuzzy matching against existing payees/accounts
  • Learns from your corrections
  • Handles CSV, OFX, and QFX formats
  • Deduplication across sources

Configuration example (import_config.py):

from beancount_import.source import ofx, csv

sources = [
    ofx.OfxSource(
        account='Assets:Bank:Checking',
        ofx_dir='~/downloads/bank',
    ),
    csv.CsvSource(
        account='Liabilities:CreditCard:Visa',
        csv_filename='~/downloads/visa.csv',
        field_mappings={
            'date': 'Date',
            'payee': 'Description',
            'amount': 'Amount',
        }
    ),
]

smart_importer: ML-Powered Categorization

GitHub: GitHub - beancount/smart_importer: Augment Beancount importers with machine learning functionality.

Uses machine learning to predict categories based on your historical data:

from beancount.ingest import extract
from smart_importer import apply_hooks, PredictPayees, PredictPostings

CONFIG = [
    apply_hooks(MyBankImporter(), [
        PredictPayees(training_data='main.beancount'),
        PredictPostings(training_data='main.beancount'),
    ])
]

After training on your ledger, it can accurately predict:

  • Payee names (standardizing “AMZN MKTP” → “Amazon”)
  • Expense categories (learns that “Whole Foods” → “Expenses:Groceries”)

bean-identify and bean-extract

Built-in tools for the classic Beancount import workflow:

# 1. Identify file types
bean-identify import-config.py ~/downloads/*.csv

# 2. Extract transactions
bean-extract import-config.py ~/downloads/bank_statement.csv > to_review.beancount

# 3. Review and merge manually
cat to_review.beancount >> main.beancount

Integration with Bank APIs

plaid2text: Modern Bank API Integration

GitHub: GitHub - madhat2r/plaid2text: Python Scripts to export Plaid transactions and transform them into Ledger or Beancount syntax formatted files.

Uses Plaid API for direct bank connectivity:

pip install plaid2text
plaid2text --account "Assets:Bank:Checking"            --days 30            --output new_transactions.beancount

Pros: Real-time data, no manual downloads
Cons: Requires Plaid API key, not all banks supported

Banking APIs in 2025

The landscape is improving:

  • US: Plaid, Yodlee, Finicity (most major banks)
  • EU: Open Banking APIs (PSD2 compliance)
  • UK: TrueLayer, Token.io

Most require developer accounts but offer free tiers for personal use.

Writing Custom Plugins

The Plugin Hook System

Beancount plugins use a simple hook architecture. Every plugin implements:

from beancount.core import data

__plugins__ = ('my_plugin',)

def my_plugin(entries, options_map, config):
    """
    Args:
      entries: List of directives (transactions, balances, etc.)
      options_map: Dict of options from the ledger file
      config: String configuration passed to plugin
    
    Returns:
      (modified_entries, errors)
    """
    errors = []
    new_entries = []
    
    for entry in entries:
        # Process each directive
        if isinstance(entry, data.Transaction):
            # Do something with transactions
            pass
        new_entries.append(entry)
    
    return new_entries, errors

Example: Auto-Split Plugin

Let’s write a plugin that automatically splits transactions tagged with #split:

# save as: my_plugins/auto_split.py

from beancount.core import data, amount
from beancount.core.data import Transaction, Posting
from decimal import Decimal

__plugins__ = ('auto_split',)

def auto_split(entries, options_map, config):
    """Auto-split transactions tagged with #split"""
    errors = []
    new_entries = []
    
    split_account = config or "Expenses:Shared:Receivable"
    
    for entry in entries:
        if isinstance(entry, Transaction) and 'split' in entry.tags:
            # Find the expense posting
            expense_posting = None
            for posting in entry.postings:
                if posting.account.startswith('Expenses:'):
                    expense_posting = posting
                    break
            
            if expense_posting:
                # Split 50/50
                original_amount = expense_posting.units
                half_amount = amount.Amount(
                    original_amount.number / Decimal('2'),
                    original_amount.currency
                )
                
                # Modify postings
                modified_postings = []
                for posting in entry.postings:
                    if posting is expense_posting:
                        # Reduce expense by half
                        modified_postings.append(
                            posting._replace(units=half_amount)
                        )
                        # Add receivable for other half
                        modified_postings.append(
                            Posting(
                                account=split_account,
                                units=half_amount,
                                cost=None,
                                price=None,
                                flag=None,
                                meta={}
                            )
                        )
                    else:
                        modified_postings.append(posting)
                
                # Create new transaction with modified postings
                entry = entry._replace(postings=modified_postings)
        
        new_entries.append(entry)
    
    return new_entries, errors

Usage:

plugin "my_plugins.auto_split" "Expenses:Shared:Receivable"

2024-01-15 * "Dinner with roommate" #split
  Expenses:Dining   80.00 USD
  Assets:Cash      -80.00 USD

; Plugin transforms to:
; Expenses:Dining                40.00 USD
; Expenses:Shared:Receivable     40.00 USD
; Assets:Cash                   -80.00 USD

Import Best Practices

1. Deduplication Strategy

Use transaction IDs in metadata:

2024-01-15 * "Grocery shopping"
  transaction_id: "BANK_12345678"
  Expenses:Groceries   50.00 USD
  Assets:Bank:Checking

Your importer can check for existing IDs before importing.

2. Categorization Workflow

Three-tier approach:

  1. Automatic: smart_importer handles 70-80% with confidence
  2. Review queue: Low-confidence predictions for manual review
  3. Manual entry: Complex transactions (splits, investments)

3. Use Importers as Libraries

Don’t just extract to files—build interactive tools:

from my_importers import BankImporter

importer = BankImporter()
transactions = importer.extract('statement.csv')

for txn in transactions:
    print(f"{txn.date} {txn.payee} {txn.amount}")
    category = input("Category? ")
    # Add to ledger with chosen category

The Future: Plugin Marketplace?

Martin Blais has mentioned interest in:

  • Plugin registry: Centralized discovery
  • Extensible syntax: Custom directives via plugins
  • Better testing tools: Plugin test framework
  • Performance improvements: Lazy loading, caching

The community is also working on:

  • Web-based import UI: beancount-import successor
  • Mobile apps: Expense capture on-the-go
  • Receipt OCR: Automatic extraction from photos

Essential Resources

What plugins do you use? Any custom ones you’ve written that might be useful for others?

Great overview! I’ve been using plugins heavily for the past 5 years and want to add practical experiences with some of these, plus a few additional ones that are incredibly useful.

Real-World Plugin Experience

The Unrealized Gains Plugin is Essential

One you didn’t mention but is critical for investment tracking:

plugin "beancount.plugins.unrealized" "Unrealized"

This automatically calculates unrealized gains/losses by creating virtual transactions. Here’s what it does:

; Your actual transaction:
2024-01-15 * "Buy AAPL"
  Assets:Investments:AAPL   10 AAPL {150.00 USD}
  Assets:Cash              -1500.00 USD

; Price updates:
2024-06-01 price AAPL  180.00 USD

; Plugin automatically creates (at balance sheet time):
2024-06-01 U "Unrealized gain for AAPL"
  Assets:Investments:AAPL   0 AAPL {150.00 USD, 2024-01-15} @ 180.00 USD
  Equity:Unrealized         -300.00 USD

This makes your balance sheet show market value while keeping cost basis intact. Without it, your Assets are understated.

beancount-import: Production Setup

I’ve been running beancount-import for 3 years. Here’s my actual config that handles 5 accounts:

# import_config.py
from beancount_import.source import ofx, csv, generic_importer
import os

def get_sources():
    downloads = os.path.expanduser('~/Downloads')
    
    return [
        # Bank checking (OFX)
        ofx.OfxSource(
            account='Assets:Bank:Checking',
            ofx_dir=os.path.join(downloads, 'bank_ofx'),
            filename_pattern=r'.*.ofx',
        ),
        
        # Credit card (CSV with custom mapping)
        csv.CsvSource(
            account='Liabilities:CreditCard:Chase',
            csv_filename=os.path.join(downloads, 'chase*.csv'),
            field_mappings={
                'date': 'Transaction Date',
                'payee': 'Description',
                'amount': 'Amount',
            },
            # Negate amounts (credit card shows purchases as positive)
            negate_amounts=True,
        ),
        
        # Venmo (custom importer)
        generic_importer.GenericImporter(
            account='Assets:Venmo',
            csv_filename=os.path.join(downloads, 'venmo*.csv'),
            date_format='%Y-%m-%dT%H:%M:%S',
            field_mappings={
                'date': 'Datetime',
                'payee': 'To',
                'narration': 'Note',
                'amount': 'Amount (total)',
            },
        ),
    ]

# For beancount-import webserver
sources = get_sources()

Key learnings:

  1. Keep separate directories for each source’s downloads
  2. Use filename patterns to avoid re-importing
  3. The web UI is worth the setup time—reviewing 50 transactions takes 2-3 minutes

smart_importer Accuracy

@doc_linker mentioned 70-80% automatic categorization. My actual numbers after 2 years:

  • Recurring transactions: 95% accuracy (Netflix → Expenses:Entertainment)
  • Common payees: 85% accuracy (Safeway → Expenses:Groceries)
  • New/rare payees: 45% accuracy (needs review)

The ML model improves over time. After 500 manually categorized transactions, it got scary good at predicting my categories.

Additional Essential Plugins

beancount-oneliner: Compact Syntax

GitHub: GitHub - Akuukis/beancount_oneliner: Addon for beancount to have one-line transactions

Allows compact transaction syntax:

plugin "beancount_oneliner.oneliner"

; Instead of:
2024-01-15 * "Coffee"
  Expenses:Coffee   5.00 USD
  Assets:Cash      -5.00 USD

; Write:
2024-01-15 * "Coffee" | Expenses:Coffee | 5.00 USD | Assets:Cash

Great for quick manual entries on mobile.

beancount_lazy_plugins.metadata

GitHub: https://github.com/tarioch/beancount_lazy_plugins

Auto-populate metadata based on patterns:

plugin "beancount_lazy_plugins.metadata" "{
  'Expenses:Dining': {'category': 'lifestyle'},
  'Expenses:Software': {'category': 'business', 'tax-deductible': 'yes'}
}"

Then query by metadata:

SELECT account, sum(position)
WHERE metadata('tax-deductible') = 'yes'

beancount-dkb: German Bank Integration

For European users, there are country-specific importers:

  • beancount-dkb: DKB Bank (Germany)
  • beancount-n26: N26 (Pan-European)
  • beancount-ing: ING Bank (Netherlands, Belgium)
  • beancount-ce: UK banks

These handle regional CSV formats and date conventions automatically.

Writing Plugins: Advanced Patterns

Pattern 1: Transaction Validation

I wrote a plugin to enforce business rules:

# Ensure all transactions over $1000 have a receipt link

from beancount.core import data
from beancount.core.data import Transaction

__plugins__ = ('require_receipts',)

def require_receipts(entries, options_map, config):
    errors = []
    threshold = float(config) if config else 1000.0
    
    for entry in entries:
        if isinstance(entry, Transaction):
            # Check all postings
            for posting in entry.postings:
                if posting.units and abs(posting.units.number) > threshold:
                    # Check for receipt metadata
                    if 'receipt' not in entry.meta:
                        errors.append(
                            ValueError(
                                f"{entry.meta['filename']}:{entry.meta['lineno']}: "
                                f"Transaction over ${threshold} missing receipt link"
                            )
                        )
                    break
    
    return entries, errors

Usage:

plugin "my_plugins.require_receipts" "1000"

2024-01-15 * "Laptop"
  receipt: "https://drive.google.com/receipt123"  ; Must have this!
  Expenses:Equipment   1500.00 USD
  Assets:Cash         -1500.00 USD

Pattern 2: Automatic Price Fetching

Instead of manually adding price directives, use a plugin:

# fetch_prices.py - runs at load time

import yfinance as yf
from datetime import date
from beancount.core import data, amount

__plugins__ = ('fetch_prices',)

def fetch_prices(entries, options_map, config):
    """Fetch current prices for all commodities"""
    
    # Find all commodities
    commodities = set()
    for entry in entries:
        if isinstance(entry, data.Commodity):
            commodities.add(entry.currency)
    
    # Fetch prices
    new_entries = list(entries)
    today = date.today()
    
    for commodity in commodities:
        if commodity in ['USD', 'EUR', 'GBP']:  # Skip currencies
            continue
        
        try:
            ticker = yf.Ticker(commodity)
            current_price = ticker.history(period='1d')['Close'].iloc[-1]
            
            # Create price directive
            price_entry = data.Price(
                meta={'filename': '<fetch_prices>', 'lineno': 0},
                date=today,
                currency=commodity,
                amount=amount.Amount(current_price, 'USD')
            )
            new_entries.append(price_entry)
        except:
            pass  # Skip if price fetch fails
    
    return new_entries, []

Now your portfolio valuation is always up-to-date without manual price entries.

Import Automation: My Complete Workflow

Here’s my end-to-end setup:

1. Automatic downloads (using bank APIs or scheduled scripts)

# cron job runs daily
0 8 * * * /usr/local/bin/download_statements.sh

2. beancount-import processing

python -m beancount_import.webserver   --data_dir ~/finances/import   --ledger ~/finances/main.beancount

Open web UI, review 5-10 transactions/day (2 minutes)

3. Balance assertions

; Run weekly - force reconciliation
2024-01-31 balance Assets:Bank:Checking  5432.10 USD

4. Monthly review

  • Run Fava
  • Check budget vs actuals (using BQL)
  • Export for tax software if needed

Total time: 15 minutes/week for complete financial tracking.

Plugin Discovery

Beyond Awesome Beancount, check:

Performance Note

Plugins run on EVERY load. If you have expensive plugins (price fetching, complex validations), load times increase:

  • 0 plugins: 1.5 seconds (50k transactions)
  • 5 plugins: 2.8 seconds
  • 10+ plugins: 4-5 seconds

Use caching or pre-computation for expensive operations.

Great thread! The plugin ecosystem is Beancount’s superpower compared to Ledger/hledger.

This is gold! I want to add a perspective on when to write custom plugins vs using existing ones, and share my custom plugin for handling split transactions with roommates.

When to Write Your Own Plugin

I’ve written 8 custom plugins over 3 years. Here’s when it’s worth the effort:

:white_check_mark: Write a Plugin When:

  1. You do the same manual transformation 10+ times/month

    • Example: Splitting rent with roommates
    • Example: Allocating business expenses across projects
  2. You need custom validation rules

    • Example: Ensuring all business expenses have project codes
    • Example: Flagging cash transactions over $500
  3. You want to enforce a workflow

    • Example: Requiring receipts for certain categories
    • Example: Auto-tagging transactions based on patterns

:cross_mark: Don’t Write a Plugin When:

  1. It’s a one-time data transformation (use a script instead)
  2. An existing plugin does 80% of what you need (extend it)
  3. It requires external API calls (use a separate import script, not a plugin)

My Custom Plugin: Split Expenses

@doc_linker showed a basic auto-split plugin. Here’s my production version that handles multiple roommates and different split ratios:

# split_expenses.py
"""
Plugin to split shared expenses among roommates.

Usage:
  plugin "split_expenses" "{
    'roommates': ['Alice', 'Bob', 'Charlie'],
    'split_account': 'Assets:Receivable:Roommates',
    'default_ratio': [1, 1, 1]
  }"

Tag transactions with #split and optionally specify ratio in metadata:
  split-ratio: "2:1:1"  (you pay 2 parts, others pay 1 part each)
"""

from beancount.core import data, amount
from beancount.core.data import Transaction, Posting
from decimal import Decimal
import json

__plugins__ = ('split_expenses',)

def split_expenses(entries, options_map, config):
    config_dict = json.loads(config) if config else {}
    roommates = config_dict.get('roommates', [])
    split_account = config_dict.get('split_account', 'Assets:Receivable:Shared')
    default_ratio = config_dict.get('default_ratio', [1] * len(roommates))
    
    errors = []
    new_entries = []
    
    for entry in entries:
        if isinstance(entry, Transaction) and 'split' in entry.tags:
            # Get split ratio from metadata
            ratio_str = entry.meta.get('split-ratio')
            if ratio_str:
                ratio = [Decimal(x) for x in ratio_str.split(':')]
            else:
                ratio = [Decimal(x) for x in default_ratio]
            
            # Calculate total parts
            total_parts = sum(ratio)
            my_parts = ratio[0]  # First person is you
            others_parts = total_parts - my_parts
            
            # Find the expense posting
            expense_posting = None
            for posting in entry.postings:
                if posting.account.startswith('Expenses:') or posting.account.startswith('Assets:'):
                    if posting.units and posting.units.number > 0:
                        expense_posting = posting
                        break
            
            if expense_posting:
                total_amount = expense_posting.units
                
                # Your share
                my_share = amount.Amount(
                    (total_amount.number * my_parts / total_parts).quantize(Decimal('0.01')),
                    total_amount.currency
                )
                
                # Others' share (what they owe you)
                others_share = amount.Amount(
                    total_amount.number - my_share.number,
                    total_amount.currency
                )
                
                # Create modified transaction
                modified_postings = []
                for posting in entry.postings:
                    if posting is expense_posting:
                        # Split the expense
                        modified_postings.append(
                            posting._replace(units=my_share)
                        )
                        if others_share.number > 0:
                            # Add receivable for others' share
                            for i, roommate in enumerate(roommates[1:], 1):
                                if i < len(ratio):
                                    roommate_share = amount.Amount(
                                        (total_amount.number * ratio[i] / total_parts).quantize(Decimal('0.01')),
                                        total_amount.currency
                                    )
                                    modified_postings.append(
                                        Posting(
                                            account=f"{split_account}:{roommate}",
                                            units=roommate_share,
                                            cost=None,
                                            price=None,
                                            flag=None,
                                            meta={'roommate': roommate}
                                        )
                                    )
                    else:
                        modified_postings.append(posting)
                
                entry = entry._replace(postings=modified_postings)
        
        new_entries.append(entry)
    
    return new_entries, errors

Usage:

plugin "split_expenses" "{
  'roommates': ['Me', 'Alice', 'Bob'],
  'split_account': 'Assets:Receivable:Roommates',
  'default_ratio': [1, 1, 1]
}"

; Equal split (default 1:1:1)
2024-01-15 * "Rent" #split
  Expenses:Housing:Rent   3000.00 USD
  Assets:Bank:Checking   -3000.00 USD

; Result:
; Expenses:Housing:Rent                 1000.00 USD (your share)
; Assets:Receivable:Roommates:Alice     1000.00 USD
; Assets:Receivable:Roommates:Bob       1000.00 USD
; Assets:Bank:Checking                 -3000.00 USD

; Custom ratio (you pay 2x because you have the master bedroom)
2024-01-15 * "Utilities" #split
  split-ratio: "2:1:1"
  Expenses:Utilities   400.00 USD
  Assets:Cash         -400.00 USD

; Result: You pay $200, others owe $100 each

Plugin Testing Best Practices

Always test plugins before using in production:

# test_split.py

from beancount import loader
from split_expenses import split_expenses

def test_equal_split():
    # Create test ledger
    test_ledger = """
    plugin "split_expenses" "{'roommates': ['Me', 'Alice']}"
    
    2024-01-01 * "Test" #split
      Expenses:Food  100.00 USD
      Assets:Cash   -100.00 USD
    """
    
    entries, errors, options = loader.load_string(test_ledger)
    
    # Check that expense is split 50/50
    for entry in entries:
        if hasattr(entry, 'postings'):
            expense_postings = [p for p in entry.postings if 'Expenses' in p.account]
            if expense_postings:
                assert expense_postings[0].units.number == Decimal('50.00')
    
    print("✓ Equal split test passed")

test_equal_split()

Debugging Plugins

When plugins don’t work as expected:

1. Add debug output:

def my_plugin(entries, options_map, config):
    import sys
    print(f"Plugin loaded with config: {config}", file=sys.stderr)
    # ... rest of plugin

2. Use bean-check to see errors:

bean-check -v myfile.beancount 2>&1 | grep -A5 "my_plugin"

3. Test with minimal ledger:

# test.beancount
plugin "my_plugin" "test config"

2024-01-01 * "Single test transaction"
  Expenses:Test  10.00 USD
  Assets:Cash   -10.00 USD

Plugin Performance Optimization

@helpful_veteran mentioned plugins slow load times. Here’s how to optimize:

Bad (iterates entries multiple times):

def slow_plugin(entries, options_map, config):
    # First pass
    transactions = [e for e in entries if isinstance(e, Transaction)]
    # Second pass
    for txn in transactions:
        # Process...
    return entries, []

Good (single pass):

def fast_plugin(entries, options_map, config):
    new_entries = []
    for entry in entries:
        if isinstance(entry, Transaction):
            # Process inline
            entry = process_transaction(entry)
        new_entries.append(entry)
    return new_entries, []

Use generators for large ledgers:

def memory_efficient_plugin(entries, options_map, config):
    for entry in entries:
        yield process_entry(entry)

Community Plugin Wishlist

Plugins I wish existed (anyone want to build these?):

  1. Auto-categorize from receipts: OCR plugin that reads receipt images and suggests categories
  2. Budget enforcement: Block ledger load if budget exceeded (strict mode)
  3. Multi-currency rebalancing: Auto-suggest trades to maintain target allocation across currencies
  4. Tax optimizer: Suggest which lots to sell for optimal tax outcome
  5. Anomaly detection: ML plugin to flag unusual transactions

Anyone working on these?

Resources for Plugin Development

The plugin system is incredibly powerful once you understand the entry data structure. Highly recommend reading the design doc!