Fix: PDF Numbers Parsed as Strings in pandas
The table extracted cleanly, the values look right, and then arithmetic fails:
df = camelot.read_pdf("in/statement.pdf")[0].df
df["Amount"].sum()
'1,234.5670.00(89.20)£45.00 1,000.00'
Instead of a total, the column concatenated. df.dtypes shows object for every column, and pd.to_numeric(df["Amount"]) raises ValueError: Unable to parse string "1,234.56" at position 0.
Root Cause
PDF extraction returns text, not values. A PDF has no type information at all — a number in a table cell is a sequence of glyphs positioned on the page, so every extraction library hands back strings and pandas infers object dtype for the column. That would be easy to fix if the strings were plain digits, but financial PDFs carry presentation with them: thousands separators, currency symbols, parenthesised negatives for credits, trailing minus signs, percent signs, and — the one that defeats most cleaning code — non-breaking or thin spaces used as digit group separators. A single unconvertible value in a column of ten thousand keeps the whole column as strings, so the failure is all-or-nothing.
Minimal Diagnostic
Find out exactly which values block the conversion, and what characters they contain.
# pip install pandas
import unicodedata
import pandas as pd
def numeric_blockers(df: pd.DataFrame, column: str, limit: int = 10) -> None:
series = df[column].astype("string")
converted = pd.to_numeric(series, errors="coerce")
bad = series[converted.isna() & series.notna() & (series.str.strip() != "")]
print(f"{column!r}: {len(series)} value(s), {len(bad)} unconvertible")
for value in bad.drop_duplicates().head(limit):
codes = " ".join(f"U+{ord(ch):04X}:{unicodedata.name(ch, '?')[:18]}"
for ch in value if not ch.isdigit() and ch != ".")
print(f" {value!r:<18} {codes}")
if __name__ == "__main__":
df = pd.read_csv("out/extracted.csv", dtype="string")
for column in df.columns:
numeric_blockers(df, column)
'Amount': 412 value(s), 391 unconvertible
'1,234.56' U+002C:COMMA
'(89.20)' U+0028:LEFT PARENTHESIS U+0029:RIGHT PARENTHESIS
'£45.00' U+00A3:POUND SIGN
'1 234.56' U+00A0:NO-BREAK SPACE
'-' U+002D:HYPHEN-MINUS
''
The codepoint dump is the part that pays off. A no-break space and a hyphen used as "nil" are invisible in a terminal, and both are common in PDFs generated by accounting software.
Fix: Normalise Then Convert, Column by Column
Strip presentation, decide what missing means, and convert with a strict check that nothing was lost.
# pip install pandas
import re
import unicodedata
import pandas as pd
CURRENCY = "\N{POUND SIGN}\N{EURO SIGN}\N{DOLLAR SIGN}\N{YEN SIGN}"
NIL_TOKENS = {"", "-", "\N{EN DASH}", "\N{EM DASH}", "n/a", "na", "nil", "none", "."}
def clean_numeric(series: pd.Series, decimal: str = ".") -> pd.Series:
text = series.astype("string")
text = text.map(lambda v: unicodedata.normalize("NFKC", v) if pd.notna(v) else v) # changed: NBSP -> space
text = text.str.strip()
text = text.mask(text.str.lower().isin(NIL_TOKENS)) # changed: nil markers -> NA
negative = text.str.match(r"^\(.*\)$", na=False) | text.str.endswith("-", na=False)
text = text.str.replace(rf"[{CURRENCY}%()\s]", "", regex=True) # changed: drop presentation
text = text.str.rstrip("-")
if decimal == ",":
text = text.str.replace(".", "", regex=False).str.replace(",", ".", regex=False)
else:
text = text.str.replace(",", "", regex=False) # changed: thousands separator
numbers = pd.to_numeric(text, errors="coerce")
numbers = numbers.mask(negative, -numbers.abs()) # changed: accounting negatives
lost = numbers.isna() & series.astype("string").str.strip().notna() & \
~series.astype("string").str.strip().str.lower().isin(NIL_TOKENS)
if lost.any():
examples = series[lost].drop_duplicates().head(5).tolist()
raise ValueError(f"{int(lost.sum())} value(s) could not be converted, e.g. {examples}")
return numbers
def clean_frame(df: pd.DataFrame, numeric: list[str], decimal: str = ".") -> pd.DataFrame:
out = df.copy()
for column in numeric:
out[column] = clean_numeric(out[column], decimal=decimal)
return out
if __name__ == "__main__":
raw = pd.read_csv("out/extracted.csv", dtype="string")
clean = clean_frame(raw, numeric=["Amount", "VAT", "Total"])
print(clean.dtypes)
print(f"total: {clean['Amount'].sum():,.2f}")
Amount float64
VAT float64
Total float64
dtype: object
total: 148,302.71
unicodedata.normalize("NFKC", ...) does more work than it looks. It turns a no-break space into an ordinary one, a full-width digit into an ASCII digit, and the various Unicode minus signs into a hyphen — three separate bugs handled by one call. Raising on unconvertible values rather than leaving them as NaN is the important decision: a silently coerced column sums to a number that is wrong by however much was dropped, and nothing about the output says so.
Variant Fix 1: Deciding Which Separator Is Which
A file of unknown origin may use either convention, and guessing wrong changes the value by a factor of a thousand without any error:
# pip install pandas
import re
import pandas as pd
def detect_decimal(series: pd.Series) -> str:
sample = series.dropna().astype(str).head(200)
comma_decimal = sample.str.contains(r"\d,\d{2}\b", regex=True).sum()
dot_decimal = sample.str.contains(r"\d\.\d{2}\b", regex=True).sum()
both = sample.str.contains(r"\d[.,]\d{3}[.,]\d{2}", regex=True).sum()
if both:
return "," if sample.str.contains(r"\d\.\d{3},\d{2}").sum() else "."
if comma_decimal and not dot_decimal:
return ","
if dot_decimal and not comma_decimal:
return "."
raise ValueError("ambiguous decimal separator; pass it explicitly")
Raising on ambiguity beats defaulting. A column of whole pounds with thousands separators — 1,234 and 5,678 — is genuinely ambiguous, and the right answer comes from knowing the document's origin, not from a heuristic. Pass the separator explicitly whenever the source is known.
Variant Fix 2: Dates and Percentages in the Same Table
Numbers are rarely the only typed column. Handle each kind explicitly rather than letting pandas infer:
# pip install pandas
import pandas as pd
def clean_percent(series: pd.Series) -> pd.Series:
text = series.astype("string").str.strip().str.rstrip("%")
return pd.to_numeric(text, errors="coerce") / 100
def clean_date(series: pd.Series, formats: list[str]) -> pd.Series:
text = series.astype("string").str.strip()
result = pd.Series(pd.NaT, index=series.index, dtype="datetime64[ns]")
for fmt in formats: # try each, never infer
pending = result.isna() & text.notna()
if not pending.any():
break
result.loc[pending] = pd.to_datetime(text[pending], format=fmt, errors="coerce")
return result
Listing the formats explicitly avoids the classic failure where 03/04/2026 parses as 3 April in one run and 4 March in the next, because pandas inferred a different convention from a different sample. If both formats appear in one column, the document itself is ambiguous and the extraction needs a column of origin, not a cleverer parser.
Reconciling Against the Document
Cleaning code that runs without error can still be wrong. A PDF statement usually prints its own total, and comparing against it is the only check that tests the whole chain — extraction, row detection and conversion together.
# pip install pandas pdfplumber
import re
import unicodedata
from decimal import Decimal
from pathlib import Path
import pdfplumber
TOTAL = re.compile(r"(?:total|balance)\D{0,20}([\d, ]+\.\d{2})", re.IGNORECASE)
def printed_total(pdf_path: Path) -> Decimal | None:
with pdfplumber.open(pdf_path) as pdf:
text = "\n".join(page.extract_text() or "" for page in pdf.pages)
text = unicodedata.normalize("NFKC", text) # no-break spaces become ordinary ones
matches = TOTAL.findall(text)
if not matches:
return None
return Decimal(matches[-1].replace(",", "").replace(" ", ""))
A mismatch does not always mean the numbers are wrong — a total may exclude VAT, or the statement may carry a balance forward. What it means is that something needs explaining before the figures are used, which is exactly the state a finance team wants an automated extraction to be in. Where the document prints no total, a row count against a printed item count serves the same purpose.
Keeping the Raw Text Alongside the Numbers
Once a column has been cleaned there is no way back to what the PDF actually said, and that is the first thing anyone asks when a figure looks wrong. Keeping both costs one column:
# pip install pandas
import pandas as pd
def with_raw(df: pd.DataFrame, numeric: list[str], decimal: str = ".") -> pd.DataFrame:
out = df.copy()
for column in numeric:
out[f"{column}_raw"] = df[column].astype("string") # exactly as extracted
out[column] = clean_numeric(df[column], decimal=decimal)
return out
The raw columns cost little in a Parquet file, since repeated presentation strings compress well, and they turn a dispute about a total into a two-minute query. They also make the cleaning rules testable against real data: any row where the cleaned value and the raw text disagree about the sign, or where the raw text contains a character the rules do not handle, can be found with a filter rather than by re-running the whole extraction.
Verification
Assert the dtypes, the absence of silent loss, and the reconciliation in one place.
# pip install pandas
from decimal import Decimal
import pandas as pd
def verify_numeric(df: pd.DataFrame, numeric: list[str], expected_total: Decimal | None = None,
column: str = "Amount", tolerance: Decimal = Decimal("0.02")) -> None:
for name in numeric:
assert pd.api.types.is_numeric_dtype(df[name]), f"{name!r} is {df[name].dtype}, not numeric"
assert not df[name].isna().all(), f"{name!r} is entirely missing after cleaning"
blank_rate = df[column].isna().mean()
assert blank_rate < 0.05, f"{blank_rate:.1%} of {column!r} is missing — cleaning likely dropped values"
if expected_total is not None:
actual = Decimal(str(round(float(df[column].sum()), 2)))
difference = abs(actual - expected_total)
assert difference <= tolerance, \
f"{column!r} sums to {actual}, document prints {expected_total} (off by {difference})"
print(f"{len(df)} row(s), {len(numeric)} numeric column(s), total {df[column].sum():,.2f} reconciled")
if __name__ == "__main__":
clean = pd.read_parquet("out/clean.parquet")
verify_numeric(clean, ["Amount", "VAT", "Total"], expected_total=Decimal("148302.71"))
The missing-rate assertion catches the case the strict conversion cannot: values that were legitimately mapped to nil by an over-broad rule. Five percent is a starting point — for a statement where blanks are genuinely rare, one percent is a better tripwire.
FAQ
Why does df.sum() concatenate instead of adding?
The column is object dtype holding strings, and + on strings concatenates. The dtype check in verification catches this.
Should I use Decimal instead of float?
For money that will be stored or reconciled, yes — convert after cleaning with df[col].map(Decimal). Floats are fine for aggregation and charts.
What about numbers split across two columns by the extractor? That is a table-detection problem, not a type problem — see extracting tables from PDFs.
How do I keep leading zeros on reference numbers?
Do not convert them. Reference numbers are identifiers, not quantities; leave them as string dtype.
Related
- Extracting PDF Data into pandas — the extraction workflow end to end
- Combine Tables from Many PDFs into One DataFrame — after each file is cleaned
- Cleaning Messy CSV Data with pandas — the same problems from a different source
- Validating Document Data with Schemas — enforcing types once they are correct
Part of Extracting PDF Data into pandas.