Whatever you use as the best PDF to CSV converter for bank statements, its CSV imports cleanly into QuickBooks Online's bank upload when it has exactly three columns (Date as MM/DD/YYYY, Description on one line, and Amount as a plain signed number with money out negative and no thousands separator) and its rows add up to the statement's printed balances. Most failed imports break one of those rules. Below is a small Python validator that checks all of them before you upload, plus a reshaper for the common two-column export.
Disclosure: I work on ZeraBooks, which makes a statement converter with a QuickBooks-ready download and ranks itself first in our comparisons. The validator works on any CSV.
What does the target layout look like?
This is the three-column layout, with a sample from a fictional Bank of America statement:
| Column | Format | Example |
|---|---|---|
| Date | MM/DD/YYYY | 09/08/2026 |
| Description | Text, one line | COUNTER CREDIT |
| Amount | Number, money out as a minus, no thousands separator | 2400.00 |
Date,Description,Amount
09/02/2026,BKOFAMERICA MOBILE 09/02 DEPOSIT *MOBILE NC,3850.00
09/03/2026,ADP PAYROLL FEES DES:ADP FEES ID:5WQ4K,-132.45
09/08/2026,COUNTER CREDIT,2400.00
QuickBooks Online's upload can also be told about a two-column layout. In practice, one signed column is the one that fails least, because there's no column mapping for anyone to get wrong.
Why do statement CSVs fail to import?
In roughly the order I see them:
- Dates without a year. Statements print "Sep 02" and put the year in the header. A naive export copies "Sep 02".
- Two money columns. Withdrawals and Deposits side by side.
-
Thousands separators. An unquoted
2,400.00is two fields to a CSV parser. - Header and summary junk. The bank name above the header row, "Balance forward" or "Total deposits" as rows.
- Wrong period. A row from the next statement, or a December row on a January statement dated to the wrong year.
-
Scientific notation. The file went through Excel, which turned a 17-digit reference into
1.23E+16and kept only 15 significant digits.
None of these are exotic. All of them are cheap to check.
The validator
Standard library only. It returns a list of problems; an empty list means upload.
import csv
import io
import re
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
HEADER = ["Date", "Description", "Amount"]
AMOUNT = re.compile(r"^-?\d+(\.\d{1,2})?$") # no thousands separator, no currency sign
JUNK = re.compile(r"(balance forward|opening balance|closing balance|^total\b)", re.I)
SCI = re.compile(r"^\d(\.\d+)?E[+-]?\d+$", re.I) # 1.23E+16: a reference number Excel mangled
def validate(text, start, end, opening=None, closing=None):
"""Check a CSV against QuickBooks Online's 3-column bank upload layout."""
errors, total, seen = [], Decimal("0"), set()
reader = csv.reader(io.StringIO(text))
header = next(reader, [])
if header != HEADER:
errors.append(f"header is {header}, expected {HEADER}")
for n, row in enumerate(reader, start=2):
if len(row) != 3:
errors.append(f"line {n}: {len(row)} fields, expected 3")
continue
d, desc, amt = row
try:
when = datetime.strptime(d, "%m/%d/%Y").date()
if not start <= when <= end:
errors.append(f"line {n}: {d} is outside {start}..{end}")
except ValueError:
errors.append(f"line {n}: date {d!r} is not MM/DD/YYYY")
if not desc.strip() or JUNK.search(desc):
errors.append(f"line {n}: description {desc!r} looks like a summary line")
if any(SCI.match(f) for f in row):
errors.append(f"line {n}: scientific notation, digits were lost")
if not AMOUNT.match(amt):
errors.append(f"line {n}: amount {amt!r} is not a plain signed number")
else:
total += Decimal(amt)
if tuple(row) in seen:
errors.append(f"line {n}: exact duplicate of an earlier row")
seen.add(tuple(row))
if opening is not None and opening + total != closing:
errors.append(f"rows don't tie: {opening} + {total} != {closing}")
return errors
good = """Date,Description,Amount
09/02/2026,BKOFAMERICA MOBILE 09/02 DEPOSIT *MOBILE NC,3850.00
09/03/2026,ADP PAYROLL FEES DES:ADP FEES ID:5WQ4K,-132.45
09/08/2026,COUNTER CREDIT,2400.00
"""
bad = """Date,Description,Withdrawals,Deposits
Sep 02,MOBILE DEPOSIT,,3850.00
09/03/2026,ADP PAYROLL FEES,-132.45
09/08/2026,COUNTER CREDIT,2,400.00
10/01/2026,Balance forward,0
"""
period = (date(2026, 9, 1), date(2026, 9, 30))
opening, closing = Decimal("10000.00"), Decimal("16117.55")
print("good file:", validate(good, *period, opening, closing) or "OK")
print("bad file:")
for e in validate(bad, *period, opening, closing):
print(" -", e)
Output:
good file: OK
bad file:
- header is ['Date', 'Description', 'Withdrawals', 'Deposits'], expected ['Date', 'Description', 'Amount']
- line 2: 4 fields, expected 3
- line 4: 4 fields, expected 3
- line 5: 10/01/2026 is outside 2026-09-01..2026-09-30
- line 5: description 'Balance forward' looks like a summary line
- rows don't tie: 10000.00 + -132.45 != 16117.55
A few design choices worth calling out:
-
It parses with
csv, neversplit(","). Descriptions contain commas. A quoted field is one field. - The period check is strict. Pass the statement's own start and end dates, not "this year". A row outside the period is either junk or belongs to another statement.
-
The tie-out is optional but you should use it. Pass the printed opening and closing balance and it checks
opening + sum(rows) == closingwithDecimal. In the bad file, two rows were unreadable, so the sum is wrong and it says so. - Duplicates are flagged, not removed. Two identical rows can be legitimate (two $16.00 fees on the same day). A human decides.
Reshaping a two-column export
If your source gives you separate Withdrawals and Deposits, plus dates without a year, reshape before validating. The year logic is the part people get wrong: on a statement that ends in January, a "Dec 30" row belongs to the previous year.
import csv
import io
from datetime import datetime
from decimal import Decimal
def money(text):
text = text.strip().replace(",", "").replace("$", "")
return Decimal(text) if text else Decimal("0")
def to_quickbooks(text, statement_year, statement_end_month):
"""Reshape a two-column statement export into Date, Description, Amount.
Dates like 'Dec 30' on a statement ending in January belong to last year."""
out = io.StringIO()
writer = csv.writer(out, lineterminator="\n")
writer.writerow(["Date", "Description", "Amount"])
for row in csv.DictReader(io.StringIO(text)):
# Parse against leap year 2000 so 'Feb 29' works, then pick the real year.
when = datetime.strptime(row["Date"] + " 2000", "%b %d %Y")
year = statement_year - 1 if when.month > statement_end_month else statement_year
amount = money(row["Deposits"]) - money(row["Withdrawals"])
desc = " ".join(row["Description"].split()) # one line, single spaces
writer.writerow([f"{when.month:02d}/{when.day:02d}/{year}", desc, f"{amount:.2f}"])
return out.getvalue()
export = '''Date,Description,Withdrawals,Deposits
Dec 30,"VENDOR ACH
NORTHWIND SUPPLY","1,204.50",
Jan 02,MOBILE DEPOSIT,,"3,850.00"
Jan 05,MONTHLY FEE,16.00,
'''
print(to_quickbooks(export, statement_year=2027, statement_end_month=1), end="")
Output:
Date,Description,Amount
12/30/2026,VENDOR ACH NORTHWIND SUPPLY,-1204.50
01/02/2027,MOBILE DEPOSIT,3850.00
01/05/2027,MONTHLY FEE,-16.00
Two details in there save real pain. Dates are parsed against the year 2000, a leap year, so a "Feb 29" row doesn't crash the parser before you've assigned the real year. And " ".join(desc.split()) collapses a wrapped description, newline and indentation included, into one line.
Run the result through validate() with the statement's printed balances and you have a file that should reconcile at zero in QuickBooks.
Do you need .QBO instead?
Only if your QuickBooks setup insists on Web Connect, which some QuickBooks Desktop workflows do. QuickBooks Online's bank upload reads CSV, QBO, QFX or OFX. Tools that write .QBO include DocuClipper (also IIF and others, page bundles from $29 for 60 pages), MoneyThumb (.qbo Web Connect, from $24.95 a month for 5 conversions), and ProperSoft (QBO, IIF and QBJ, desktop, from $19.99 a month per user and device), all checked October 11, 2026. AutoEntry downloads a .qbo or a CSV at 3 credits a statement page (checked November 5, 2026).
ZeraBooks doesn't write .QBO and doesn't connect to QuickBooks. It writes the CSV above, from rows already proven against the statement's balances. The trade-offs between the routes in, CSV upload, Web Connect and the bank feed, are laid out in ZeraBooks' QuickBooks converter ranking.
What about Xero and Sage?
Their statement imports read their own column layouts, so a QuickBooks file isn't automatically a Xero file. The same validation ideas apply: one row per transaction, an unambiguous date, a signed amount, no summary rows, and a tie-out. ZeraBooks downloads separate QuickBooks-ready, Xero-ready and Sage-ready layouts from the same proven statement, plus Standard, Money in and out, and All details.
If you're comparing converters on the CSV they produce, ZeraBooks ranked nine of them on layouts offered, which software each fits, and whether rows were checked before the file was written, in its list of the best PDF to CSV converter for bank statements. Hubdoc came second there: it writes a CSV for QuickBooks or Xero and flags statements whose totals don't match.
Where to put this in a pipeline
Run the validator at the last step before upload, and fail loudly. A CSV that imports with a wrong sign or a wrong year is worse than one that doesn't import at all, because the reconciliation that catches it happens weeks later.
If you only add one check, add the tie-out. Every other rule here catches a format problem. The tie-out catches the problems that look fine.

Top comments (0)