Wo sein sauer verdientes Geld hingeht, findet Mike Schilli mittels Auswertung der aus dem Finanzprogramm YNAB exportierten Buchungsdaten heraus.
Egal ob mit Quicken oder Mint oder mit dem etwas volkstümlicheren (wenngleich gebührenpflichtigen) YNAB (You Need A Budget): Der Weg zum finanziellen Erfolg steht nur denjenigen offen, die ihre Eingaben und Ausgaben mit Adleraugen im Blick behalten. Im Snapshot aus dem Linux-Magazin 02/2025 [1] habe ich das Thema schon einmal angerissen und eine Terminal-UI aus Go zur Abfrage der aktuellen Pegelstände meiner Geldspeicher vorgestellt. In dieser Ausgabe verlagert sich der Fokus aufs Auswerten. Wo und für was habe ich mein sauer verdientes Geld ausgegeben?
Da sich YNAB kumpelhaft offen gibt, erlaubt es auch den Export (Abbildung 1) aller bislang eingegebenen Daten im CSV-Format (Abbildung 2). In den gut anderthalb Jahren meiner YNAB-Nutzung hat sich mit insgesamt 1976 Buchungen (Abbildung 3) einiges angesammelt – ein Eldorado für statistische Auswertungen.
Mit oder ohne BOM
An der exportierten CSV-Datei beißt sich allerdings der CSV-Parser von Go die Zähne aus. Die ersten drei Bytes EF BB BF (Abbildung 4) definieren die sogenannte Byte Order Mark (BOM) für UTF8-codierten Text. Tatsächlich wirft sie schlicht die meisten Kommaparser aus der Bahn.
Bevor Listing 1 anfängt, die CSV-Daten zeilenweise einzulesen, setzt es mit »NewReader()« einen neuen CSV-Parser auf. Mittels »BOMOverride()« in Zeile 14 bekam er vorher einen Transformator spendiert. Der schluckt die drei Bytes bevor die Karussellfahrt beginnt und nordet den Parser gleich auf den nun folgenden UTF8-Text ein.
Da zweitausend Zeilen ein Klacks für moderne Rechner sind, liest »ReadAll()« in Zeile 16 alle CSV-Zeilen in einem Rutsch ein und gibt einen Array-Slice mit allen Datenreihen zurück, die wiederum als Array-Slices von Strings vorliegen.
Listing 1
csv.go
package main
import (
"encoding/csv"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
"os"
)
func CSVRows(path string) ([][]string, error) {
file, err := os.Open(path)
if err != nil {
return [][]string{}, err
}
defer file.Close()
utf8bom := unicode.BOMOverride(unicode.UTF8.NewDecoder())
r := csv.NewReader(transform.NewReader(file, utf8bom))
rows, err := r.ReadAll()
return rows, err
}
Wohin das Geld geht
Nun zur Auswertung der Buchungsdaten. YNAB weist jeder Buchung eine Kategorie zu; grob unterscheidet die Software Needs und Wants, also notwendige Ausgaben und zusätzliche Käufe. Außerdem kommt eine Unterkategorie hinzu, die definiert was man explizit erworben hat. Needs: Rent steht zum Beispiel für die Wohnungsmiete, während Wants: Travel Reisen repräsentiert.
Nun schließen sich direkt einige Fragen an: In welche zehn Kategorien fließt nun das meiste Geld ab? Um welchen Betrag handelt es sich genau? Wie stehen die Kategorien untereinander im Verhältnis? Listing 2 hangelt sich durch die von Listing 1 eingelesenen Buchungsdaten und summiert sie in Zeile 16 in Map-Einträgen der Variable »categorySpend« unter dem Namen der jeweiligen Kategorie auf.
Die For-Schleife ab Zeile 13 verwirft die erste Header-Zeile der CSV-Datei mit »[1:]«. Danach greift sie die Spalten 5 und 9 (Index 4 und 8) jeder Zeile ab, in die der YNAB-Exporteur den Namen der Kategorie und den abgeflossenen Geldbetrag abgelegt hat. In der Spalte 9 steht eine Fließkommazahl mit führendem Währungssymbol, das Zeile 15 mit »[1:]« abschneidet, bevor der Zahlenparser »ParseFloat« sich des numerischen Eintrags annimmt.
Listing 2
bycat.go
package main
import (
"fmt"
"sort"
"strconv"
)
func main() {
rows, err := CSVRows("register.csv")
if err != nil {
panic(err)
}
categorySpend := make(map[string]float64)
for _, row := range rows[1:] {
category := row[4]
outflow, _ := strconv.ParseFloat(row[8][1:], 64)
categorySpend[category] += outflow
}
type kv struct {
Key string
Value float64
}
var sorted []kv
for k, v := range categorySpend {
sorted = append(sorted, kv{k, v})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Value > sorted[j].Value
})
n := 10
if len(sorted) < 10 {
n = len(sorted)
}
for i := 0; i < n; i++ {
fmt.Printf("%s,%.2f\n", sorted[i].Key, sorted[i].Value)
}
}
Um die Top 10 zu ermitteln, gilt es, die Map-Einträge in einem Array absteigend nach dem größten Budget zu sortieren. Das funktioniert in Go nicht so elegant wie in mancher Skriptsprache. Hashmaps kommen von Haus aus unsortiert daher und nur Arrays lassen sich sortieren. Die dafür in Zeile 18 definierte Struktur »kv« nimmt den Namen einer Kategorie in »Key« und den Budgetwert in »Value« auf. Einen Array dieser Strukturen enthält die in Zeile 22 definierte Variable »sorted«.
Die Standardfunktion »sort.Slice()« in Zeile 26 bringt die Einträge absteigend nach dem numerischen Wert in »Value« in die richtige Reihenfolge. Der als zweites Argument beigefügte Callback nennt sich auf der Manualseite »less«. Er gibt also einen wahren Wert zurück, falls die zwei Einträge an den Indexpositionen »i« und »j« so im sortierten Array stehen sollen, dass der an Position »i« vor dem an Position »j« zu liegen kommt.
Die Funktion »Slice()« sortiert den Array an Ort und Stelle, verschiebt also die Elemente lediglich im bereits existierenden Array, ohne irgendwo Kopien anzulegen. Nun stehen in »sorted« die Einträge mit den höchsten Umsätzen zuerst und die For-Schleife ab Zeile 33 gibt die ersten zehn Wertepaare zeilenweise auf der Standardausgabe im CSV-Format aus.
Pizzazz statt Zahlen
Drögen Zahlenreihen fehlt der nötige Pep. Deswegen macht Listing 3 ein Histogramm mit Balken daraus. Moderne Unix-Terminals zeigen glücklicherweise nicht nur ASCII-Text an, sondern beherrschen auch manches Unicode-Zeichen zum Erstellen von Terminal-UI-Grafiken. Abbildung 5 zeigt die Ausgabe der Top 10 der geldverschlingenden Kategorien im Terminal, aufgemotzt mit farbigen Balken und Emojis.
Das dazu aufgerufene Kommando »bycat« ist das Binary, das aus den ersten beiden Listing mit »go build bycat.go csv.go« entstanden ist. Seine Ausgabe fließt durch die getippte Pipe ins Programm »histo«, das die gezeigte Grafik generiert.

Abbildung 5: Nach Kategorien aufgespaltete Ausgaben lassen sich im Terminal mit farbigen Balken und Emojis aufbrezeln.
Listing 3 zeigt das dazugehörige Hauptprogramm, das zunächst die als CSV hereinkommenden String- und Float-Wertepaare zeilenweise einliest. Darüber hinaus berechnet es, wie viel Platz der jeweils breiteste Eintrag in den beiden Spalten einnimmt. Schließlich gilt es, jeden Eintrag später in der Ausgabe entsprechend seiner tatsächlichen Länge mit Leerzeichen aufzufüllen, damit die Tabellenspalten ordentlich aussehen.
Ra-Ru-Rick, Printf-Trick
Die Funktion »fmt.Printf()« in Zeile 38 reserviert unter Zuhilfenahme der etwas weiter unten definierten Funktion »pad()« entsprechend Platz, indem sie kürzere Einträge mit Leerzeichen längt. Zeile 47 nutzt dazu eine nicht allzu bekannte Funktion der Platzhalter von »Printf()« aus dem Standardpaket »fmt«. Viele kennen »%20s« oder »%-20s«, um einen String rechtsbündig oder linksbündig in einen 20 Zeichen breiten Behälter einzufügen und den Rest mit Leerzeichen aufzufüllen.
Aber was ist zu tun, falls die Länge des Behälters nicht statisch feststeht und somit nicht hartcodiert im Formatstring stehen darf? Der Trick ist hier, als Platzhalter »%*s« im Formatstring anzugeben und »Printf()« statt einem Parameter später zwei mitzugeben: Erst die dynamische Länge als Integer-Variable, danach den zu formatierenden String.
Wenn also Zeile 47 in Listing 3 »%*s« im Formatstring stehen hat und »Sprintf()« später den numerischen Wert »padding« und den Nullstring (!) »””« mitgibt, macht die Funktion daraus einen Leerstring mit der in »padding« festgelegten Anzahl von Leerzeichen. Raffiniert!
Listing 3
histo.go
package main
import (
"encoding/csv"
"fmt"
"github.com/mattn/go-runewidth"
"os"
"strconv"
"strings"
)
func main() {
reader := csv.NewReader(os.Stdin)
records, err := reader.ReadAll()
if err != nil {
panic(err)
}
maxVal := 0.0
maxKeyLen := 0
sum := 0.0
for _, rec := range records {
key := rec[0]
val, _ := strconv.ParseFloat(rec[1], 64)
if len(key) > maxKeyLen {
maxKeyLen = len(key)
}
if val > maxVal {
maxVal = val
}
sum += val
}
if maxVal == 0 {
maxVal = 1
}
for _, rec := range records {
key := rec[0]
val, _ := strconv.ParseFloat(rec[1], 64)
width := int(val * 30 / maxVal)
bar := strings.Repeat("\033[34m\u2593\033[0m", width)
fmt.Printf("%s | %s %.1f%%\n", pad(key, maxKeyLen),
bar, val*100.0/sum)
}
}
func pad(s string, width int) string {
dispw := runewidth.StringWidth(s)
padding := width - dispw
if padding < 0 {
padding = 0
}
return fmt.Sprintf("%s%*s", s, padding, "")
}
Formatierfalle Emoji
Nun kapriziert sich YNAB darauf, in die Kategorietexte Emojis einzubauen, also ein blaues Spielzeugauto für Kfz-Wartung oder ein Häuschen für die Miete. Das sieht lustig aus, wirft aber den Formatierer der Funktion »Printf()« des Standardpakets »fmt« aus der Bahn.
Listing 4
car.go
package main
import (
"fmt"
"github.com/mattn/go-runewidth"
)
func main() {
car := "\U0001F697"
fmt.Printf("%s\n", car)
fmt.Printf("%d chars long.\n", len(car))
fmt.Printf("%d display width.\n", runewidth.StringWidth(car))
}

Abbildung 6: Tappen Sie nicht in die Formatierfalle und achten Sie auf falsche und richtige Längen von Strings mit Emojis.
Nehmen wir zum Beispiel das Auto-Emoji, das unter dem Unicode-Punkt »\U0001F697« firmiert. Listing 4 legt es im String »car« in Zeile 7 ab. Gos eingebaute Funktion »len()« behauptet nun in Zeile 9, dieser String sei vier Zeichen breit. Tatsächlich breitet sich das Auto-Emoji in einem Terminal allerdings über zwei Zeichen aus, was die Ausgabe in Abbildung 6 zeigt. Zu Hilfe kommt hier das Paket »runewidth« auf Github, dessen Funktion »StringWidth()« Emojis kennt und die Darstellungsbreite des Strings korrekt als zwei ermittelt.
Zurück zu Listing 3: Die einzelnen Rechtecke, von denen das Histogramm-Programm eine Anzahl aneinandergereiht als Balken bestimmter Länge ausgibt, sind auch Unicode-Zeichen. Im vorliegenden Fall ist dies das Blockzeichen am Code-Punkt »U+2593«, das einen Dark Shade, also ein dunkles, kariertes Muster zeigt. Die blaue Farbe kommt in Zeile 37 durch ANSI-Color-Escape-Sequenzen in die Balkengrafik. Sie beeinflussen entweder den Vorder- oder Hintergrund eines dargestellten Zeichens.
In der Standardpalette stehen 16 Farben zur Verfügung. Die Umstellung auf eine andere Farbe leitet jeweils ein Escape-Zeichen ein (»\033« im String). Anschließend folgt ein alphanumerischer Code, zum Beispiel »[34m]« für blauen Vordergrund oder »[0m« zum Zurücksetzen auf den normalen Vordergrund. Zeile 37 tut genau das für jedes einzelne Rechteck in den Balken des Histogramms. Damit Sie die blaue Farbe auch bei Ihnen im Terminal sehen, müssen Sie die ANSI-Color-Funktion Ihres Terminals einschalten.
Die Küche bleibt kalt
Nach der Monatsmiete für die Wohnung sind Restaurantbesuche ein weiterer kapitalintensiver Posten. Wie wäre es mit einer monatlichen Aufstellung der über die letzten anderthalb Jahre in Schlemmerlokalen verpulverten Geldbeträge? Zu diesem Zweck iteriert Listing 5 wieder durch die CSV-Daten des YNAB-Exporters und erstellt eine Hashmap »monthSpend«, die als Schlüssel einen Monat im Format “YYYY/MM” führt und als Werte die in dem Monat aufsummierten Restaurantrechnungen.
Dazu muss Zeile 19 mit »time.Parse()« das Datum jeder Buchung aus der CSV-Datei auslesen. Das passiert mit dem in Go üblichen Formatstring »01/02/2006« (Monat, Tag, Jahr). Für den Hashmap-Schlüssel macht »Format(“2006/01”)« aus der Variablen vom Type »time.Time« wieder einen String, und zwar im Format YYYY/MM. So kann der Code in Zeile 23 eine Restaurantrechnung dem jeweiligen Monat in der Hashmap zuordnen.
Die etwas merkwürdigen Namen der Platzhalter in Gos Time-Parser und -Formatierer basieren auf einem leicht zu merkenden Datum: dem 2. Januar, um 3 Uhr, 4 Minuten und 5 Sekunden im Jahre 2006. Der Amerikaner stellt in Datumsangaben bekanntlich den Monat vor den Tag, also ergibt sich 1-2-3-4-5-6. Wer also ein deutsches Datum im Format TT.MM.JJJJ einlesen oder ausgeben möchte, würde »02/01/2006« in den Formatstring schreiben.
Listing 5
dining.go
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
func main() {
rows, err := CSVRows("register.csv")
if err != nil {
panic(err)
}
monthSpend := make(map[string]float64)
for _, row := range rows[1:] {
if !strings.Contains(row[4], "Dining") {
continue
}
outflow, _ := strconv.ParseFloat(row[8][1:], 64)
t, err := time.Parse("01/02/2006", row[2])
if err != nil {
panic(err)
}
monthSpend[t.Format("2006/01")] += outflow
}
for k, v := range monthSpend {
fmt.Printf("%s,%.2f\n", k, v)
}
}
Alle Schlüssel im Format YYYY/MM lassen sich später wegen des vorstehenden Jahrs schlauerweise als Strings alphabetisch sortieren, und heraus kommt ganz automatisch die richtige zeitliche Reihenfolge.
Analog zum vorher vorgestellten Analyseprogramm nach Kategorien kompiliert sich das Binary »dining« aus »go build dining.go csv.go«, vorausgesetzt es wurden vorher alle Abhängigkeiten mit »go mod init ynab; go mod tidy« aufgelöst.
Abbildung 7 zeigt den Aufruf des Binarys »dining«, gefolgt von einem »sort«-Kommando der Unix-Shell und anschließend von »histo«, dem Binary aus Listing 3. Die Ausgabe zeigt eine kurze Restaurantflaute im Sommer 2024, gefolgt von vermehrten Restaurantbesuchen in der kälteren Jahreszeit.

Abbildung 7: Monatliche Ausgaben beim Auswärtsessen verraten beispielsweise, zu welcher Jahreszeit sie häufiger Restaurants aufsuchen.
Fazit: Automatisch
Die 2000 Buchungen habe ich übrigens nicht von Hand eingetippt, sondern aus Kreditkartenabrechnungen extrahiert. YNAB bietet zwar an, sich in regelmäßigen Abständen bei Banken- und Kartenwebseiten einzuloggen, vorausgesetzt man hinterlegt seine Login-Daten, aber das erscheint mir recht leichtsinnig.
Stattdessen liegt auf meinen Github-Account [2] ein Go-Projekt namens Go-ynabler. Es wandelt die CSV-Dateien bekannter Kreditinstitute in YNAB-kompatibles CSV um, das sich dann über YNABs Import-Funktion einlesen lässt. Wer das einmal im Monat tut, hält seine Buchungen auf dem aktuellen Stand.
Die beiden vorgestellten Auswertungen markieren erst den Anfang. Eine weitere Idee wären zum Beispiel ein gestapeltes Balkendiagramm mit den monatlichen Ausgaben. Damit ließe sich feststellen, ob Ausgaben von einer Kategorie in eine andere abgewandert sind. Ganz so, wie der Wahlonkel im Fernsehen das Ergebnis kommentiert, ließe sich dann bei der monatlichen Familienkonferenz unter dem Motto “Rückblick und Strategiewechsel” ein sicher willkommener Vortrag zum Haushaltsbudget halten. Wie immer sind der Kreativität keine Grenzen gesetzt – wenn saubere Daten vorliegen und Go im Werkzeuggürtel steckt! (uba)
Infos
- Michael Schilli, “Ohne Moos nix los”: Linux-Magazin 02/25: https://www.linux-magazin.de/ausgaben/2025/02/snapshot/
- Go-ynabler: https://github.com/mschilli/go-ynabler










