Handling Structured Data: The CSV Challenge
Anonymizing a PDF is hard, but anonymizing a CSV or Excel file is deceptively difficult. You cannot simply pass a CSV string to an LLM or an NLP model; the lack of sentence structure confuses the context-aware models. We adopted a hybrid approach in anonymize/csv.py that combines heuristic rules with NLP, processed via multithreading for performance.
1. Multithreaded Row Processing
NLP inference is CPU-bound and slow. Processing a 10,000-row CSV sequentially would take forever. We utilized Python's ThreadPoolExecutor to redact rows concurrently.
# From anonymize/csv.py
with ThreadPoolExecutor(max_workers=MAX_THREADS) as executor:
futures = {executor.submit(redact_row, row): idx for idx, row in enumerate(df_sample)}
# ... process results as they complete ...
2. Smart Heuristics
For structured data, we don't rely solely on the models. Specific logic handles common fields, which saves compute and is more predictable than model inference for well-defined formats.
Emails: Detected via regex patterns (@ symbols) and strictly redacted.
Headers: We check column headers. If a column is named "Full Name," we force a [PER] redaction even if the model is unsure.
Numerics: Purely numeric cells are currently skipped, so amounts, balances and quantities stay usable for AI in financial reporting and compliance workflows.
The numeric rule is a trade-off, not a safe default. Numbers are not inherently non-sensitive. A ten-digit value in a "Contact" column is a phone number, a 9-16 digit value may be an account, card or national identifier, and identifiers stored as numbers are common in spreadsheets. A salary or invoice total on its own is usually not personal data, though it can become identifying alongside a role or date. A context-aware policy is safer than skipping by data type. It reads the header and neighbouring columns to decide whether a numeric column is an identifier or a measure, and runs format validators (such as the Luhn check for card numbers or the IBAN checksum) on numeric-looking values. It also defaults to redaction where the context is ambiguous, and preserves numeric columns only once they are confirmed to be measures.
3. Column-Level Thresholding
This is my favorite feature. Once we process a sample of the data, we calculate a "redaction ratio" for each column. If more than 80% of a column contains PII (e.g., a column of home addresses), we don't bother processing the rest. We wipe the entire column and replace it with the most common placeholder (e.g., [ADDRESS]).
The threshold is a heuristic computed from a sample, and it trades leakage risk against data utility. Three limits are worth knowing. The ratio depends on detector recall: if the models miss a third of the values in an address column, the measured ratio falls below the threshold, the column is processed cell by cell, and the same misses stay in the output. A sample that is not representative, such as the first rows of a file sorted by date or region, can misjudge the whole column. And wiping a column replaces every distinct value with one placeholder, which protects the values but removes the ability to group, join or deduplicate on that column.
The 80% figure is therefore a starting point for one use case, not a standard. A deployment may reasonably use a much lower threshold, or any detection at all, for columns whose headers mark direct identifiers, and a higher one for free-text columns where PII is sporadic.
The granularity of masking should follow the data. Column-level masking suits homogeneous identifier columns such as addresses or account numbers. Cell-level masking suits mixed or free-text columns, such as notes, where sensitive values appear occasionally among useful text. Row-level masking suits cases where sensitivity belongs to the record rather than a field, for example a person who has withdrawn consent, or a row whose combination of fields would identify someone even with the names removed.
4. Headers, Missing Values and Malformed Rows
Headers and cell contents are separate signals. A header such as "Full Name" is a strong prior, but headers like "Col3", "Notes" or "Ref" say little, and cell content can contradict them. Headers can also contain PII, for example in pivoted tables where customer names are column labels, so they should be scanned like any other text.
Empty cells and placeholders such as "N/A" should not count toward a column's redaction ratio. Malformed rows deserve more care: a ragged CSV line or an unquoted delimiter can shift a value into the wrong column, so a header-based rule is applied to the wrong data and an address can end up in a "Notes" field. A safe pipeline fails closed on such rows, quarantining or flagging them rather than passing them through. Inconsistent formatting, such as phone numbers with and without separators, identifiers with spaces, dates in mixed formats and numbers stored as text, belongs in the test set, because each is a way for a detector to miss.
File Reconstruction
We support .docx, .pdf, and .xlsx. The challenge here isn't just reading the text, but putting it back together without breaking the file format.
This capability is particularly valuable for healthcare organizations, financial institutions, and enterprises processing sensitive documents.
For PDFs, we use a sequence of pdfplumber to extract text, pass it through our anonymize pipeline, and then use reportlab to generate a brand new PDF stream containing the safe, categorized text.
For Excel, we deconstruct the workbook into DataFrames, process them using the CSV logic mentioned above, and then reconstruct the workbook using openpyxl, preserving the sheet structure.
What Reconstruction Can Leak
Rebuilding a file is a privacy step in its own right, because a document carries more than its visible text, and how the output is produced decides what survives.
Generating a new PDF with reportlab from extracted text, as described above, has a useful side effect. Anything the extractor did not return, such as document properties, annotations, form fields or embedded files, is not copied into the new file unless the code adds it. The cost is fidelity: layout, tables and images are not preserved, and a scanned PDF with no text layer gives pdfplumber nothing to extract, so image-only pages need OCR and the same detection before they can be handled. Editing a document in place preserves more, but also keeps everything you do not explicitly clean: tracked changes, comments, headers and footers, document properties and embedded objects in DOCX.
Spreadsheets have more hiding places: hidden sheets, rows and columns, cell comments, formulas, defined names and document properties such as the author. Loading sheets into DataFrames and writing a fresh workbook typically drops comments, defined names and properties unless the code copies them, and returns formula results rather than formula text. Whether hidden sheets are read and processed or skipped should be confirmed, not assumed.
Whichever route is used, validate the result. Reopen the output with the same parser, check that sheet names, row and column counts and header positions match the input (allowing for deliberately removed columns), and search the raw output, including its metadata and XML parts, for known test identifiers.