Merged and split rows are the most common structural errors to test for when you extract a table from a bank statement PDF or judge the best PDF to Excel converter for bank statements, and you can catch nearly all of them with two passes: a cleaning pass that folds wrapped lines back into the transaction they belong to and drops page furniture, then a running-balance pass that flags any row whose printed balance doesn't follow from the previous one plus its own amount. This post has a runnable Python version of both.
Disclosure: I work on ZeraBooks, a statement converter, and we rank ourselves first in our own comparisons. Nothing below depends on our product.
What do "split" and "merged" actually look like?
If you've exported a statement with a general PDF-to-table tool, you've seen these. A statement is laid out for a human reader, and page-layout extraction keeps the layout instead of the transactions.
Split rows come in two flavours:
- Wrapped description. "Business PAD WORKSAFEBC" on one line, "PREMIUM Q3 2026" on the next. The second line becomes its own row with no date and no amount.
- Split amount. The description wraps and the amount prints on the second line, beside the overflow text. You get one row with a date but no amount, then one with an amount but no date.
Merged rows are the opposite. Two transactions with tight line spacing get read as one: one date, a description with a second date stuck in the middle, and only one of the amounts. That one is nasty, because the output still looks like a valid row.
Page furniture sits in between: "Page 1 of 2", the repeated column header at the top of page 2, "Balance forward", "Continued on next page", subtotal lines. Each becomes a row unless you remove it.
How do you fold split rows back together?
The rules are simple once you write them down:
- Drop anything that matches known page furniture first, before folding. Otherwise "Page 1 of 2" gets glued onto the previous description.
- A line with no date and no amount is wrapped text. Append it to the previous row's description.
- A line with an amount but no date, right after a row with a date but no amount, is a split amount. Move the amount up.
- Anything else is a new transaction.
Then the second pass uses the statement's own running balance. If previous balance + amount != printed balance, something is wrong with that row. If the description also contains a second date token, two transactions were probably merged into one.
import re from decimal import Decimal JUNK = re.compile(r"^(date\s+description|balance (brought )?forward|continued on|page \d+ of \d+|total (deposits|withdrawals))", re.I) DATE_IN_TEXT = re.compile(r"\b\d{2}/\d{2}\b") def clean(raw): """raw: list of dicts with date, desc, amount, balance (strings, maybe empty), as a page-layout PDF export gives them. Returns rows plus notes on what changed.""" rows, notes = [], [] for line in raw: if JUNK.match(line["desc"]): notes.append(f"dropped junk line: {line['desc']!r}") continue has_date, has_amount = bool(line["date"]), bool(line["amount"]) if rows and not has_date and not has_amount: rows[-1]["desc"] += " " + line["desc"] notes.append(f"joined wrapped text onto {rows[-1]['date']}") continue if rows and not has_date and has_amount and not rows[-1]["amount"]: rows[-1].update(amount=line["amount"], balance=line["balance"]) if line["desc"]: rows[-1]["desc"] += " " + line["desc"] notes.append(f"joined split amount onto {rows[-1]['date']}") continue rows.append(dict(line)) return rows, notes def suspects(rows, opening): """Flag rows whose printed balance doesn't follow from the previous one.""" prev, flagged = Decimal(opening), [] for r in rows: amount, balance = Decimal(r["amount"]), Decimal(r["balance"]) if prev + amount != balance: why = "two transactions merged?" if DATE_IN_TEXT.search(r["desc"]) else "amount misread?" flagged.append((r["date"], r["desc"], balance - prev - amount, why)) prev = balance return flagged raw = [ {"date": "09/02", "desc": "Mobile deposit", "amount": "4800.00", "balance": "36004.66"}, {"date": "09/03", "desc": "Online payment BC HYDRO ACCOUNT", "amount": "", "balance": ""}, {"date": "", "desc": "REF 7781-0042", "amount": "-284.10", "balance": "35720.56"}, {"date": "09/04", "desc": "Business PAD WORKSAFEBC", "amount": "-612.44", "balance": "35108.12"}, {"date": "", "desc": "PREMIUM Q3 2026", "amount": "", "balance": ""}, {"date": "", "desc": "Page 1 of 2", "amount": "", "balance": ""}, {"date": "", "desc": "Date Description Withdrawals Deposits Balance", "amount": "", "balance": ""}, {"date": "", "desc": "Balance forward", "amount": "", "balance": "35108.12"}, {"date": "09/08", "desc": "MONERIS SETTLE 09/09 MONERIS SETTLE", "amount": "6214.85", "balance": "43522.97"}, ] rows, notes = clean(raw) for n in notes: print("-", n) print(f"{len(raw)} raw lines -> {len(rows)} transactions") for date, desc, gap, why in suspects(rows, "31204.66"): print(f"CHECK {date} {desc!r}: balance moved {gap} more than the amount ({why})") Output:
- joined split amount onto 09/03 - joined wrapped text onto 09/04 - dropped junk line: 'Page 1 of 2' - dropped junk line: 'Date Description Withdrawals Deposits Balance' - dropped junk line: 'Balance forward' 9 raw lines -> 4 transactions CHECK 09/08 'MONERIS SETTLE 09/09 MONERIS SETTLE': balance moved 2200.00 more than the amount (two transactions merged?) Nine raw lines become four transactions. The last one is flagged: its printed balance moved 2,200.00 more than its own amount, and there's a second date (09/09) inside the description. That's a second deposit of 2,200.00 swallowed by the first row. The fix is to split it into two transactions, and the gap tells you the missing amount.
Why use the balance column instead of smarter parsing?
Because the balance column is the bank telling you what happened. Layout heuristics guess. The running balance doesn't.
The cleaning rules above handle the common shapes. They'll miss odd ones: a bank that prints the amount on the first line and the date on the second, a description that legitimately contains a date ("REFUND OF 09/02 PURCHASE"), a statement with no running balance at all. The balance pass catches the misses of the cleaning pass, which is the point of having two.
A few edge cases to handle in real code:
- No running balance. Some statements print balances only on the summary page. Then you can't localise the error per row, but you can still tie out the whole account: opening plus all rows equals closing.
- Balance printed once per day. Some banks print the balance only on the last transaction of each day. Compare at those rows and sum the amounts in between.
- Multiple accounts in one PDF. Reset the running balance when the account changes. A credit union statement with checking and savings in one file is common.
- Legit dates in descriptions. Use the date token only as a hint for the reason, never as the trigger. The balance mismatch is the trigger.
What does clean output look like?
One row per transaction, a full date on every row, one description cell, one signed amount, and the account on every row. Here's a converted statement in that shape:
That's a fictional Monzo business statement, converted and downloaded in ZeraBooks' Standard layout, then opened. No wrapped rows, no page headers, nothing to fold.
Where do general PDF tools fall down here?
It's worth knowing what each tool does by design, so you know which errors to expect:
- Adobe Acrobat exports the page as you see it, each table, page or document on a worksheet. A two-page statement can arrive as two worksheets with split descriptions.
- Excel Power Query (Get Data from PDF, Windows only) lists each detected table and page as its own query for you to combine. It reads only the PDF's text, so scans are out.
- ProperSoft shows each transaction for you to review, edit or exclude before writing the file, so a person catches splits by eye.
- DocuClipper is a statement converter, so rows come out as transactions rather than page layout.
ZeraBooks ranked eight tools on exactly this, what actually lands in the spreadsheet, in its comparison of the best PDF to Excel converter for bank statements. The rival details come from each vendor's own pages, checked between October 11 and November 8, 2026.
Should you build this or buy it?
If you're building a statement pipeline into your own product, you'll write some version of this code no matter which extraction API you use, because no API is right every time and the running balance is your cheapest safety net. Veryfi returns beginning and ending balances, which gives you the tie-out. Nanonets' own guide says to compare extracted rows with the original PDF.
If you just need the rows, a converter that does both passes for you is less code to own. The difference between the two kinds of tool, a file for a person versus data for a system, is laid out in ZeraBooks' ranking of statement extraction tools, nine of them from no-code converters to enterprise APIs.
Whichever you pick, keep the balance pass. The cleaning rules will drift as banks change layouts. The arithmetic won't.

