DEV Community

Damin Mutti for ZeraBooks

Posted on

A pre-flight validator for QuickBooks Online bank CSV uploads

A QuickBooks Online bank CSV should be validated before upload against two sets of rules: Intuit's format rules (3 or 4 columns, 1,000 lines or fewer, 350 KB or less, one date format, no zeros in Credit or Debit) and the statement's own arithmetic (opening plus rows equals closing). Below is a small Python validator for both. ZeraBooks Convert runs the arithmetic check before it lets you download.

I'm on the ZeraBooks team. Our converter outputs QuickBooks-ready CSVs, so we spend a lot of time on what QuickBooks Online will and won't accept, and on what it will accept that's still wrong. The second category is the dangerous one. A file that uploads cleanly can still be missing a line, and once it's in the For review list you won't see it until reconciliation fails.

Intuit's rules, as checks

From Intuit's help article on manually uploading transactions (updated August 24, 2026):

  • 3 columns: Date, Description, Amount. Or 4: Date, Description, Credit, Debit.
  • File size 350 KB or less, up to 1,000 lines per upload.
  • One date format throughout.
  • In the 4-column layout, leave the empty side blank, not zero.
  • Don't put the word "amount" in the Credit or Debit headers.

Here they are as code:

import csv, io, os, re
from datetime import datetime
from decimal import Decimal, InvalidOperation

MAX_BYTES = 350 * 1024
MAX_ROWS = 1000
DATE_FORMATS = ["%m/%d/%Y", "%d/%m/%Y", "%Y-%m-%d"]

def detect_date_format(values):
    for fmt in DATE_FORMATS:
        try:
            for v in values:
                datetime.strptime(v, fmt)
            return fmt
        except ValueError:
            continue
    return None

def money(s):
    s = s.strip().replace(",", "")
    if s == "":
        return None
    try:
        return Decimal(s)
    except InvalidOperation:
        raise ValueError(f"not a number: {s!r}")

def validate_qbo_csv(path):
    errors = []
    if os.path.getsize(path) > MAX_BYTES:
        errors.append(f"file is {os.path.getsize(path)} bytes, limit {MAX_BYTES}")

    with open(path, newline="", encoding="utf-8-sig") as f:
        rows = list(csv.reader(f))
    header, data = rows[0], rows[1:]
    if len(data) > MAX_ROWS:
        errors.append(f"{len(data)} rows, limit {MAX_ROWS}; split by date")

    cols = [h.strip().lower() for h in header]
    if len(cols) == 3:
        layout = "3col"
    elif len(cols) == 4:
        layout = "4col"
        for h in cols[2:]:
            if "amount" in h:
                errors.append(f"header {h!r} must not contain 'amount'")
    else:
        return [f"expected 3 or 4 columns, got {len(cols)}"], None

    fmt = detect_date_format([r[0] for r in data])
    if fmt is None:
        errors.append("dates are not in one consistent format")

    signed = []
    for n, r in enumerate(data, start=2):
        if layout == "3col":
            amt = money(r[2])
            if amt is None:
                errors.append(f"line {n}: empty amount")
                continue
        else:
            cr, dr = money(r[2]), money(r[3])
            if (cr is None) == (dr is None):
                errors.append(f"line {n}: needs exactly one of Credit/Debit")
                continue
            if (cr is not None and cr == 0) or (dr is not None and dr == 0):
                errors.append(f"line {n}: use a blank, not 0")
            amt = cr if cr is not None else -dr
        signed.append(amt)
    return errors, signed
Enter fullscreen mode Exit fullscreen mode

detect_date_format checks that one format parses every row. Mixed formats are common when someone pastes two exports together. A file where 03/04/2026 is ambiguous will still pass; that's a limit of looking at strings, and why the next check matters more.

The check Intuit doesn't run: does the file tie?

QuickBooks has no way to know your statement's balances. You do. Opening balance plus every signed row should land on the printed closing balance:

def ties(signed, opening, closing):
    total = opening + sum(signed, Decimal("0"))
    return total == closing, total - closing
Enter fullscreen mode Exit fullscreen mode

A fictional quarter: checking ending 2290, July to September 2026, in one PDF. Opening 8,214.55, deposits 31,402.10, withdrawals 29,880.43, closing printed as 9,736.22.

errors, signed = validate_qbo_csv("chk_2290_2026Q3.csv")
ok, diff = ties(signed, Decimal("8214.55"), Decimal("9736.22"))
print(errors or "format ok", "| ties" if ok else f"| off by {diff}")
Enter fullscreen mode Exit fullscreen mode

8,214.55 + 31,402.10 − 29,880.43 = 9,736.22. If diff isn't zero, don't upload. A diff equal to one row's amount means that row is missing or doubled. A diff of twice a row means its sign is flipped.

Splitting a big year

A busy account can blow through 1,000 rows. Split on month boundaries, not at row 1,000, so each chunk starts and ends on a statement date and can be tied on its own:

from collections import OrderedDict

def split_by_month(rows, date_fmt, max_rows=MAX_ROWS):
    months = OrderedDict()
    for r in rows:
        key = datetime.strptime(r[0], date_fmt).strftime("%Y-%m")
        months.setdefault(key, []).append(r)
    chunks, current = [], []
    for key, mrows in months.items():
        if current and len(current) + len(mrows) > max_rows:
            chunks.append(current)
            current = []
        current.extend(mrows)
    if current:
        chunks.append(current)
    return chunks
Enter fullscreen mode Exit fullscreen mode

1,640 rows over a year, for example, comes out as two files of whole months. If a single month is over 1,000 rows on its own, you'll need to split inside it by date and tie against a mid-month balance if the statement prints one.

Overlap with what's already there

The last check isn't about the file at all: find the last date already in that QuickBooks account and drop rows on or before it. Overlap is where most duplicates in For review come from.

Where ZeraBooks fits

ZeraBooks Convert reads the PDF (scans and multi-account PDFs included), splits by account and cardholder, checks each statement against its printed opening and closing balance to the cent, and gives you a QuickBooks-ready CSV in the 3-column layout, one file per account. It doesn't connect to QuickBooks and doesn't make .qbo or IIF files. It's in the ZeraBooks subscription, $79 a month billed annually or $149 monthly, after a 7-day trial.

The non-code walkthrough, with all four ways to get a PDF into QuickBooks Online, is our guide on importing a PDF bank statement into QuickBooks.

Disclosure: I'm part of the ZeraBooks team.

Top comments (0)