๐ Money Data ยท 8 min read
Bank Statement to CSV: Why the Totals Don't Add Up
A bank's CSV export is not a clean table. It normally has two or three lines of account information before the header row and a totals or closing-balance row after the last transaction, and its amounts may be split into separate debit and credit columns or quoted as text. Parse it as a plain table and one of three things happens: it stops with a field-count error, the debits come out doubled, or the amounts stay text and never sum at all. All three are avoidable, and each one is measurable.
Below: what the export actually contains, the exact numbers we got from a four-transaction statement, and the four checks that tell you whether a parsed file is trustworthy before you build anything on top of it.
What a statement export actually contains
RFC 4180, the CSV specification, defines the format as an optional header line followed by records โ and explicitly says the presence or absence of that header must be told to the parser from outside the file. That is the root of the problem here: a bank export is a valid CSV whose first line is not the header, and nothing inside the file says so.
In practice the preamble looks like this โ the bank name, the masked account number, and the statement period, one item per line:
ACME BANK โ STATEMENT EXPORT Account: ****4821 Period: 2026-09-01 to 2026-09-30 Date,Description,Debit,Credit,Balance 2026-09-02,"PAYMENT TO SUPPLIER, SHENZHEN",4200.00,,15800.00 ...
And the ending looks like this โ a row whose first cell is a label, not a date, and whose numeric cells repeat the column totals:
Ending balance,,10235.00,9750.00,19515.00
Both blocks are ordinary CSV records. Neither is a transaction. That is the whole trap.
What we measured
We built a 372-byte export with three preamble lines, a five-column header, four transactions (two debits, two credits) and a closing row, then read it with pandas 2.3.3 on Python 3.13.5 on 2026-11-02. Script and fixtures are kept under seo_geo_ops/nc_measure_20261102/.
| Attempt | What happened |
|---|---|
read_csv("bank_raw.csv") | ParserError: Error tokenizing data. C error: Expected 1 fields in line 4, saw 5 |
header=None | Same error โ the preamble has no commas, the data does |
skiprows=3 | shape (5, 5) โ four transactions plus the closing row, columns Date/Description/Debit/Credit/Balance |
| Debit sum, closing row included | 20,470.00 โ exactly double the real 10,235.00 |
| Debit + Credit, added together | 39,970.00 โ two directions of movement summed as if they were one |
| Credit โ Debit, signed | โ485.00 โ the number that matches the statement |
The doubling is the dangerous one, because nothing about it looks wrong. The column parses as float64, every value is a plausible amount, and the total is wrong by a factor of two. If you upload that file to a dashboard and ask "how much did we spend", the answer will be exactly double, stated with confidence.
Fix 1: tell the parser where the header is
In pandas, skiprows is the parameter that exists for this. Count the preamble lines โ three in our fixture โ and skip them so the header row becomes row zero. The same idea in Excel is Data โ From Text/CSV, where the preview lets you delete the top rows and set the header row by hand before importing. Both are the same instruction: the file has structure, and you are telling the tool what it is.
If the preamble length differs between banks (it will), do not hardcode a count. Open the file in a text editor, find the line that contains the field names, and use its line number. A file that opens in one column is the same symptom seen from another angle โ the one-column diagnosis covers the delimiter half of that problem.
Fix 2: drop the totals row
The closing row is not always labelled the same way, so filtering by the first cell is more robust than filtering by position: keep only rows whose date column parses as a date, and the labels fall out automatically.
df = pd.read_csv("bank_raw.csv", skiprows=3)
df = df[pd.to_datetime(df["Date"], errors="coerce").notna()]
df[["Debit", "Credit", "Balance"]] = df[["Debit", "Credit", "Balance"]].apply(pd.to_numeric, errors="coerce")Note the second line: errors="coerce" turns any surviving label into NaN instead of raising, which then makes a wrong column type visible at once. A column of amounts that arrives as object instead of float64 is the single most useful signal that something in that column was not a number.
Fix 3: debit and credit are directions, not amounts
Two columns with blanks in opposite rows is a signed quantity split in half. In our fixture the Debit column had one value and three blanks, the Credit column had three values and one blank, and adding the columns together produced 39,970.00 against a true net movement of โ485.00. Build the signed column once and use it everywhere:
df["Amount"] = df["Credit"].fillna(0) - df["Debit"].fillna(0) print(df["Amount"].sum()) # -485.0
Some exports go the other way and give you a single amount column plus a type column ("DEBIT" / "CREDIT"), or a single signed column. All three shapes are common; the check is the same, and it is arithmetic, not opinion: opening balance plus the signed sum must equal the closing balance.
Fix 4: amounts that are quoted text
This one surprised us. We wrote three amounts โ "1,234.56", "12,345.00" and "1.234,56" โ quoted, as many exports do. Passing thousands="," did nothing: the column stayed object and the values came through unchanged. pandas parses numbers from the raw field; a quoted field containing a comma is a string, and the parameter never gets a chance.
The consequence is nasty because Python will happily sum strings. Our naive sum returned 1,234.5612,345.001.234,56 โ concatenation, not addition, presented as a result. The repair is explicit: strip quotes and separators first, then convert, then count your NaNs.
raw = df["Amount"].astype(str).str.replace('"', "", regex=False).str.replace(",", "", regex=False)
df["Amount"] = pd.to_numeric(raw, errors="coerce")
print(df["Amount"].isna().sum()) # must be 0 before you trust the totalCareful with a comma-decimal locale: there the comma is the decimal point, so removing every comma turns 1.234,56 into 1.23456. Decide which convention the file uses โ one file, one convention โ and say so in your notes. The separator and decimal-comma trap is the same decision seen from the export side.
Four checks before you trust any total
- Row count. The parsed rows should equal the transaction count the statement reports. If the statement says 143 and you have 144, you kept the totals row.
- Balance arithmetic. Opening balance + credits โ debits = closing balance. If it does not, a row is missing, duplicated, or signed the wrong way.
- Column types. Amounts must be numeric, dates must be dates. Any
objectcolumn holding money will fail you later, quietly. - Date range. Earliest and latest transaction dates must sit inside the period stated in the preamble. A transaction from the previous month means the export included a carry-over you did not expect.
One extra habit that saves real money: never let a leading-zero account or reference number become a number. Card and account references are identifiers, not quantities โ keeping leading zeros is the same class of bug, and it also destroys matching against other systems.
If the bank only gives you a PDF
Many banks export PDF by default and CSV only from a desktop session. Extracting a table from a PDF is a different job with a different failure mode: the numbers arrive as text positioned on a page, and columns are inferred from coordinates. Extracting a table from a PDF into CSV covers that path, including how to verify the extraction before you aggregate it.
Whatever the source, keep the original file next to the parsed one. When a total is questioned six months later, the export is the evidence โ a derived spreadsheet is not.
Frequently Asked Questions
Why does my bank statement CSV not open as a table?
Because the file is not only a table. Exports commonly put the account number, the statement period and the bank name in the first lines before the real header row, and put a totals or closing-balance row at the end. A parser that assumes row one is the header finds one field where it expects five and stops: in our test pandas raised ParserError Expected 1 fields in line 4, saw 5. The fix is to tell the parser where the header actually is and where the data ends.
Why did my debit total come out exactly double?
Because the statement's own total row was counted as a transaction. Our four test transactions summed to 10,235.00 in debits, and the closing row in the same column held 10,235.00, so reading every numeric row gave 20,470.00. A doubling is the signature of this mistake, and it is silent: the numbers are valid, the column type is float, and no error is raised. Drop the last row, or filter rows where the date column is empty, before summing.
Should I add the debit and credit columns together?
No. Debit and credit are two directions of movement, not two amounts to add. In our file one row had a debit and three had credits, and adding both columns gave 39,970.00 while the actual net movement was -485.00. Build a signed amount instead: credit minus debit, with blanks treated as zero. Then a total means something.
Why are my amounts showing as text instead of numbers?
Because the export quotes them. A value written as "1,234.56" is a quoted string containing commas, and pandas keeps it as text even when you pass thousands=',' โ we measured exactly that: the column stayed object dtype and the three test values came through unchanged. Summing text concatenates it, so 1,234.56, 12,345.00 and 1.234,56 became the string 1,234.5612,345.001.234,56. Strip the quotes and the thousands separators first, then convert to numeric and check that nothing became NaN.
What is the fastest way to check a CSV export is complete?
Three checks, in this order: the row count should equal the number of transactions the statement says it contains; opening balance plus credits minus debits should equal the closing balance; and the date range of the rows should match the period in the preamble. If any of the three is off, the parse is wrong, not the bank. None of these checks require a spreadsheet โ a row count and two sums are enough.
Can I analyse a bank statement CSV without Excel or code?
Yes, if the tool tells you what it parsed. Upload the file to a browser-based analyser and read three numbers before trusting any answer: how many rows were parsed, which delimiter was detected, and what the columns were named. A file with a preamble typically shows up as one column with an odd name, which is the signal that the header row was not where the parser expected it.
Sources
- RFC 4180, Common Format and MIME Type for CSV Files, section 2: the file grammar is an optional header line followed by records; "the presence or absence of the header line must be indicated via the optional header parameter" of the media type, i.e. it cannot be inferred from the file alone. Section 2 also states that fields containing commas should be enclosed in double quotes โ the reason quoted amounts arrive as text. Retrieved 2026-11-02.
- pandas 2.3.3 documentation, pandas.read_csv: the
skiprowsparameter (line numbers to skip, or a callable),thousands(thousands separator) anddecimal(decimal character), andheader. Retrieved 2026-11-02. - Local measurement, 2026-11-02, Python 3.13.5 / pandas 2.3.3, fixtures and script at
seo_geo_ops/nc_measure_20261102/: a 372-byte statement export with 3 preamble lines, header, 4 transactions and a closing row; defaultread_csvraisedParserError: Expected 1 fields in line 4, saw 5;skiprows=3returned shape (5, 5); debit sum including the closing row 20,470.00 versus 10,235.00 after filtering; Debit+Credit 39,970.00 versus a signed net of โ485.00; a quoted-amount fixture stayedobjecteven withthousands=","and naive summation returned the concatenated string 1,234.5612,345.001.234,56.
See What It Parsed Before You Trust the Total
The analyzer reports the rows it parsed, the delimiter it found and the column types โ so a statement whose header was swallowed shows up immediately instead of as a doubled total three steps later.
Related reading
Data Cleaning โ other guides that pair well with this one.
- CSV Character Limit: How Many Characters Can a Cell Hold?
- Why Is My CSV File Not Uploading? 8 Causes and Fixes
- CSV Writer Adding Blank Rows: Why csv.writer Puts an Empty Line Between Records
- Convert CSV to PDF
Browse all guides in the NoCodeCSV blog.