Moving from Beancount 2.x to 3.x

A few days ago, Beancount officially switched from 2.x to 3.x. If I'm being honest, I was a bit afraid of this release, mostly because it was supposed to be a major rewrite. I'm generally not a huge fan of rewrites, and I don't think I'm the only one on the Internet who thinks that way.

Luckily, the update seems to have been less of an event as I anticipated. If you're planning to move from Beancount 2.x to 3.x, the update should be mostly uneventful. Nevertheless, there are a few important changes that may affect your workflow. The rest of this blog post will summarize the ones that I've noticed so far.

1. External data import workflow

The first thing I noticed is that the workflow for importing external data looks different.

With 2.x, you had to maintain a config.py file in your project root where you would import all your Importer classes and put them inside a CONFIG variable.

from first_bank_importer import FirstImporter
from second_bank_importer import SecondImporter

CONFIG = [FirstImporter(), SecondImporter()]

The Beancount commands like bean-extract and bean-identify would then pick this file and run through the list of specified importers to find the matching one.

In 3.x, this workflow is gone. Commands like bean-extract and bean-identify have been removed from the beancount package. Instead, the project has decided to go for a script-based workflow instead (source). It appears that you don't need to maintain a config.py anymore, and your importers need to provide a command-line entry point that can do those tasks (eg. identifying whether a given file matches an importer or extracting a list of transactions out of a file).

If you maintain custom importers and distribute them through PyPI, the one immediate downside of this approach is how those importers are initialized. With the config.py approach, your users could instantiate importers just as any Python code, because it was literally just Python code. Here's an example of how that used to look like:

from my_importer import MyImporter

CONFIG = [
    MyImporter(
        name="Assets:MyBank:Checking",
        currency="EUR",
        patterns={"ALDI": "Expenses:Supermarket:ALDI"},
    )
]

In the absence of config.py, you'll need to find a different way of doing this.

I maintain a few Beancount importers myself and for those, I decided to put their initialization parameters inside pyproject.toml. The pyproject.toml file is a relatively new Python standard that is used by a few other tools in the Python ecosystem to place their configuration bits (eg. inside a tool.X section), amongst other information. My reasoning here is that if you're using Beancount, your finances project is basically a Python project, which may as well contain a pyproject.toml file, which in turn means putting importer configuration in there isn't all that bad.

As an example, one of the importers I maintain is beancount-dkb that provides two kinds of importers for two different kinds of bank accounts: EC accounts and Credit accounts. For this package, I wrote a cli.py module that provides two entrypoint functions (one for each kind of bank account) that parse the importer configuration out of pyproject.toml and use it to instantiate the importer class. Here's what the importer configuration looks like:

[tool.beancount-dkb.ec]
name = "Assets:MyBank:Checking"
currency = "EUR"
patterns = [
    ["ALDI", "Expenses:Supermarket:ALDI"]
]

In addition, when the user installs the beancount-dkb package, they also get two CLI commands, beancount-dkb-ec and beancount-dkb-credit, that call the entrypoint functions defined inside the previously mentioned cli.py.

These two things lead to the user being able to run beancount-dkb-ec extract /path/to/file.csv to extract a list of transactions from the CSV export they downloaded from their bank's website.

Of course, this is just one way of approaching things. I'm sure there are more ways to approach this problem.

2. Fava compatibility

This one is unfortunate. It seems like Fava isn't compatible with Beancount 3.x, yet. There are a couple of Github issues (eg. #1824 and #1831) that are open at the time of this writing. So far I haven't seen much activity on those issues, so I'm not sure what the current state is.

If you're upgrading to Beancount 3.x, be aware that you'll lose out on Fava.

3. Adjusting Importers

The next thing I noticed is that the importer class definitions need to be adjusted because of API changes.

The beancount.ingest package is gone and has been replaced with the beangulp module which contains the new importer base (abstract) class and a few other utilities to work with external data. The changes are not many and are not very big either. Here's a quick list:

3.1. Updated base importer class

The first change is, of course, that beancount.ingest is gone. Well, not completely gone, but at least gone from beancount. This functionality has now been extracted to the beangulp module.

In Beancount 2.x the importers were inheriting from beancount.ingest.importer.ImporterProtocol. In 3.x, they should now inherit from beangulp.importer.Importer.

from beangulp.importer import Importer

class MyImporter(Importer):
    ...

3.2. Updated file pointer type in method signatures

The second change is in the method signatures of methods like extract or identify. In beancount.ingest.importer.ImporterProtocol, these methods used to accept a file-like object (cache._FileMemo) as a parameter.

def extract(self, file: cache._FileMemo):
    pass

This file-like object has been replaced with a string filepath, which is a bit more straightforward to work with.

def extract(self, filepath: str):
    pass

3.3. Importers don't have self.FLAG anymore

This is a minor one, but I think it might be worth mentioning. If your importers are using self.FLAG in any of the methods, it won't work anymore. You'll need to replace it with one of the flags defined in beancount.core.flags.

3.4. Method names

And the final change I noticed is how the importer method names have changed.

beangulp.importer.Importer defines a new interface that importers should implement. The identify and extract methods have been kept. Other than that, the file_account, file_date, and file_name methods have been renamed to remove the file_ prefix. So file_account has become account, file_date has become date, and file_name has become filename.

The class is defined here, in case you'd like to see the complete interface yourself.


And that's pretty much it. After making the above changes to your importers and your workflow, your setup should be compatible with Beancount 3.x. As I mentioned earlier, the list of changes is not very big. And the changes themselves aren't that big either. However, I couldn't find a migration document that would guide me through the process, so I thought about writing a quick one up myself.

I hope this blog post was helpful. If you have any questions (or noticed something in this blog post that is not correct), please feel free to reach out to me on Bluesky!

(Oh and btw, if you'd like to learn more about how you can track your personal finances using Python and Beancount and don't mind sticking with Beancount 2.x for now, I wrote a book on the topic. 🙃)