Data Formats (JSON, YAML, TOML, XML, CSV) Module 9 – CSV and tabular text
Messy CSV: Excel, encodings and Indian formats
Read spreadsheet and bank CSV exports without losing digits, zeros or dates: declared column types, Indian amounts, day-first dates and checked sniffing.
What you will learn
- Read CSV exported by spreadsheets and banks without corrupting numbers, dates or text
- Declare every column's type instead of trusting type inference
- Parse amounts in Indian digit grouping and day-first dates, and refuse every other form
- Detect a file's delimiter and header, and recognise when the detection has failed
Before you start
What you’ll learn · Before you start
What you will learn
- Read CSV exported by spreadsheets and banks without corrupting numbers, dates or text
- Declare every column's type instead of trusting type inference
- Parse amounts in Indian digit grouping and day-first dates, and refuse every other form
- Detect a file's delimiter and header, and recognise when the detection has failed
Before you start
On this page
A bank’s export lists the reference 400123456789012345, the
The area code of an Indian landline number, written with its leading 0 and dialled before the subscriber's number, such as 0484.
0484 and the amount 1,23,456.50, and every one of them is valid CSV. Open the file in a spreadsheet and the
reference loses its last digits and the code its zero; read it with automatic typing in code and the code loses its
zero again while the amount stays text, all without an error.
You will see which values spreadsheets change and why, read the same export with a type declared for every column, parse amounts in Indian digit grouping: The separators a locale puts between groups of digits in a number. English in the United States writes 123,456,789.5; English in India groups by three and then by two, as 12,34,56,789.5. A parser removes them by the documented rule. and day-first dates with parsers that refuse to guess, and check what delimiter and header detection return before you trust it.
A spreadsheet changes four kinds of values when it opens a CSV file
CSV has no types, so a spreadsheet decides what each cell is when it opens the file. Microsoft documents what Excel does by default: it removes leading zeros from numerical text, cuts numerical data to 15 digits of precision and shows it in scientific notation, reads digits around the letter E as scientific notation, and turns some strings of letters and numbers into dates. Its specifications put the precision of a number at 15 digits, and a number with more has every digit after the fifteenth set to zero. This program applies the first three rules to a bank export, the way the documentation describes them, and prints each cell they change:
"""The cells of a bank export that a spreadsheet changes when it opens the file, by the conversions Microsoft documents."""
import csv
import io
import re
EXPORT = (
"txn_date,narration,ref_no,withdrawal,deposit,std_code,mobile,batch\r\n"
'04/10/2026,NEFT Asha Traders,400123456789012345,"1,23,456.50",,0484,09876543210,12E4\r\n'
"13/10/2026,Cash deposit,400123456789012340,,2500.00,0484,09812345678,12E5\r\n"
)
def spreadsheet_value(text):
"""What a spreadsheet keeps of a cell with its default conversions, and why; None when the text stays text."""
if re.fullmatch(r"\d+", text):
digits = text.lstrip("0") or "0"
if len(digits) > 15:
return digits[:15] + "0" * (len(digits) - 15), "15 digits kept, the rest become 0"
if digits != text:
return digits, "leading zeros removed"
return None
if re.fullmatch(r"\d+E\d+", text):
mantissa, power = text.split("E")
return str(int(mantissa) * 10 ** int(power)), "digits around E read as a power of ten"
return None
kept = {}
for row in csv.DictReader(io.StringIO(EXPORT, newline="")):
for column, text in row.items():
changed = spreadsheet_value(text)
if changed:
value, why = changed
print(f"{column:<8} {text:<20} -> {value:<20} {why}")
kept.setdefault(column, []).append(value)
refs = kept["ref_no"]
print("two references, one number:", refs[0] == refs[1], refs[0]) Output
ref_no 400123456789012345 -> 400123456789012000 15 digits kept, the rest become 0 std_code 0484 -> 484 leading zeros removed mobile 09876543210 -> 9876543210 leading zeros removed batch 12E4 -> 120000 digits around E read as a power of ten ref_no 400123456789012340 -> 400123456789012000 15 digits kept, the rest become 0 std_code 0484 -> 484 leading zeros removed mobile 09812345678 -> 9812345678 leading zeros removed batch 12E5 -> 1200000 digits around E read as a power of ten two references, one number: True 400123456789012000
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 at_risk.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Under those three rules, four of the eight columns change. The references lose their last three digits, and the two
different references 400123456789012345 and 400123456789012340 become the same number. The STD code and the
mobile number lose their leading zero, and the batch codes 12E4 and 12E5 become 120,000 and 1,200,000. Once a cell
holds the changed value, the original digits are gone from the workbook and from every file saved from it.
| Value in the file | Kind of value | What the default conversion does |
|---|---|---|
400123456789012345 |
a reference of 18 digits | keeps 15 digits, the rest become zeros |
0484, 09876543210 |
codes and phone numbers | removes the leading zero |
12E4 |
a batch code | reads it as 12 × 10⁴ |
| letters and digits together | codes of many kinds | some become dates |
The cure is to keep such columns text from the moment the file is opened. Microsoft’s own advice for long numbers is a text format, set before the values arrive, or an import that sets the column’s type to Text; a format applied after the values are in the cells does not bring the digits back.
CSV to Excel Converter Turn the export into an .xlsx whose code, phone and reference columns stay text, so people can open it in a spreadsheet safely.Checkpoint
Check: What a spreadsheet changes on opening
Automatic typing loses data in code too, so declare each column’s type
Spreadsheets are not the only guessers. Many CSV readers offer type inference: Guessing a column's type from the text of its values, as the automatic typing of spreadsheets and CSV libraries does. It changes values without an error: 0484 becomes 484, and a long reference loses digits.: Papa
Parse converts numeric and boolean text when dynamicTyping is on, though it leaves numbers beyond 2^53 as text to keep
their digits, and csv-parse tries to convert every field with cast: true. At heart the guess is the same everywhere:
if the text parses as a number, it becomes one. This program reads the export three ways, as text, with that guess,
and with a type declared for every column; the JavaScript tab makes the same guess with Number():
Python · types/three_ways.py
"""One bank export read three ways: as text, with guessed types, and with a type declared for every column."""
import csv
import io
from datetime import datetime
EXPORT = (
"txn_date,narration,ref_no,withdrawal,deposit,std_code,mobile\r\n"
'04/10/2026,NEFT Asha Traders,400123456789012345,"1,23,456.50",,0484,09876543210\r\n'
"13/10/2026,Cash deposit,400123456789012340,,2500.00,0484,09812345678\r\n"
)
def guess(text):
"""Automatic typing: the first of int and float that accepts the text, else the text itself."""
for convert in (int, float):
try:
return convert(text)
except ValueError:
pass
return text
def paise(text):
"""An amount such as 1,23,456.50 in whole paise: grouping commas removed on purpose, empty means no amount."""
if not text:
return None
rupees, _, fraction = text.replace(",", "").partition(".")
return int(rupees) * 100 + int(fraction.ljust(2, "0"))
DECLARED = {
"txn_date": lambda text: datetime.strptime(text, "%d/%m/%Y").date().isoformat(),
"narration": str,
"ref_no": str,
"withdrawal": paise,
"deposit": paise,
"std_code": str,
"mobile": str,
}
rows = list(csv.DictReader(io.StringIO(EXPORT, newline="")))
for n, row in enumerate(rows, start=1):
print(f"row {n:<9} {'text':<21} {'guessed':<21} declared")
for column, text in row.items():
print(f" {column:<10} {text!r:<21} {guess(text)!r:<21} {DECLARED[column](text)!r}") Output
row 1 text guessed declared txn_date '04/10/2026' '04/10/2026' '2026-10-04' narration 'NEFT Asha Traders' 'NEFT Asha Traders' 'NEFT Asha Traders' ref_no '400123456789012345' 400123456789012345 '400123456789012345' withdrawal '1,23,456.50' '1,23,456.50' 12345650 deposit '' '' None std_code '0484' 484 '0484' mobile '09876543210' 9876543210 '09876543210' row 2 text guessed declared txn_date '13/10/2026' '13/10/2026' '2026-10-13' narration 'Cash deposit' 'Cash deposit' 'Cash deposit' ref_no '400123456789012340' 400123456789012340 '400123456789012340' withdrawal '' '' None deposit '2500.00' 2500.0 250000 std_code '0484' 484 '0484' mobile '09812345678' 9812345678 '09812345678'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 three_ways.py
JavaScript · types/three_ways.mjs
// The same export typed the way many converters do it in JavaScript: Number() on every field that is not text.
const rows = [
{ ref_no: "400123456789012345", withdrawal: "1,23,456.50", deposit: "", std_code: "0484", mobile: "09876543210" },
{ ref_no: "400123456789012340", withdrawal: "", deposit: "2500.00", std_code: "0484", mobile: "09812345678" },
];
const guess = (text) => (Number.isNaN(Number(text)) ? text : Number(text));
rows.forEach((row, n) => {
console.log(`row ${n + 1}`);
for (const [column, text] of Object.entries(row)) {
console.log(` ${column.padEnd(10)} text ${JSON.stringify(text).padEnd(22)} guessed ${JSON.stringify(guess(text))}`);
}
});
const [a, b] = rows.map((row) => guess(row.ref_no));
console.log("two references, one number:", a === b, Number.isSafeInteger(a), Number.MAX_SAFE_INTEGER);
console.log('Number("0x1A") is', Number("0x1A"), 'and Number(" ") is', Number(" ")); Output
row 1
ref_no text "400123456789012345" guessed 400123456789012350
withdrawal text "1,23,456.50" guessed "1,23,456.50"
deposit text "" guessed 0
std_code text "0484" guessed 484
mobile text "09876543210" guessed 9876543210
row 2
ref_no text "400123456789012340" guessed 400123456789012350
withdrawal text "" guessed 0
deposit text "2500.00" guessed 2500
std_code text "0484" guessed 484
mobile text "09812345678" guessed 9812345678
two references, one number: true false 9007199254740991
Number("0x1A") is 26 and Number(" ") is 0
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node three_ways.mjs
Runs on this device, in your browser. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
Python’s guess kept the long references exact, because Python’s int has no size limit, but it dropped the zeros of
both codes and gave the amounts no type you could rely on: the grouped withdrawal stayed text, and deposit is an
empty string in row 1 and the float 2500.0 in row 2. JavaScript did worse. Number("400123456789012345") is above
the largest safe integer: An integer that binary64 holds exactly and shares with no other integer: from -(2^53 - 1) to 2^53 - 1, which is 9007199254740991. Number.isSafeInteger tests for it. More in “Numbers: precision, big integers and decimals”, 9,007,199,254,740,991, so both references became
400123456789012350 and compare equal. An empty withdrawal became 0, which looks like a real amount, and Number() also accepts 0x1A as 26
and blank text as 0, as the ECMAScript conversion rules allow.
The declared column of the output is what a program should hold. Each column has one converter, chosen once for the
whole file: references, codes and phone numbers stay text, amounts become whole paise from their text, an empty amount
becomes None rather than 0, and the date is read as day first because the bank’s documentation says so.
Converting identifiers to numbers
Wrong: int(row["mobile"]) or Number(row.ref_no), which drops a leading zero or the last digits. Right: keep
identifiers as text; a value is a number only when you will do arithmetic with it, and then you convert it from
text by a rule you chose.
The converters belong to the file, not to each program that reads it: write them down once, with the sender’s description, and every reader gets the same values.
Key idea
Declare one type per column for the whole file, from the sender’s description, and keep identifiers as text: a guess made value by value gives one column several types and changes values without an error.
Checkpoint
Check: Declared column types
Indian amounts and day-first dates need parsers that refuse to guess
The amount 1,23,456.50 is grouped by locale, and so is the date 04/10/2026, and a parser has to know which locale
wrote them. Digit grouping differs by locale: the Unicode CLDR pattern for English in India is #,##,##0.###, three
digits at the right and then groups of two, while English in the United States groups by threes. Node’s Intl prints
the same number and the same day for both:
// The same number and the same day written by Intl for two locales: what an export from each looks like.
const amount = 123456789.5;
const day = new Date(Date.UTC(2026, 9, 4));
for (const locale of ["en-IN", "en-US"]) {
const number = new Intl.NumberFormat(locale).format(amount);
const date = new Intl.DateTimeFormat(locale, { timeZone: "UTC" }).format(day);
console.log(`${locale}: ${number.padEnd(15)} ${date}`);
} Output
en-IN: 12,34,56,789.5 4/10/2026 en-US: 123,456,789.5 10/4/2026
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node format_in.mjs
Lakh and crore grouping
Amounts formatted for India follow this pattern, which counts in lakh (1,00,000) and crore (1,00,00,000), so
1,23,456.50 means 1 lakh plus 23,456.50, and 12,34,56,789.50 means 12 crore plus 34,56,789.50. Dates formatted
for India put the day first, as Intl did above. A parser for such files removes the grouping commas on purpose,
after checking that they sit where Indian grouping puts them.
So an amount parser checks the form before it converts anything, and it stops at the first thing it did not expect. Step through four amounts:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A grouped amount. The first cell is 1,23,456.50. The parser keeps it as text until it knows the form: no conversion has happened yet.
- Indian grouping. There is no sign to drop. The digits before the point are grouped 1, 23, 456: three digits at the right, then twos. That is the en-IN pattern, so the commas can be removed on purpose.
- Two decimals, then paise. The part after the point is 50, two digits. The parser joins 123456 and 50 into 12345650 paise, an exact whole number.
- A sign and a larger amount. The second cell starts with the rupee sign and a space. Dropping them leaves 12,34,56,789.50, whose groups are 12, 34, 56 and 789, so it becomes 12345678950 paise.
- Grouped in threes. The third cell is 123,456.50, grouped in threes. In a file whose documentation says Indian grouping, that is a different convention or a typing error, so the parser stops instead of guessing.
- One decimal only. The fourth cell is 12.5. Its digits are fine, but an amount in this export has two decimals or none, so a single one means the cell was edited or cut, and it is refused too.
- Refusals carry the line. An import collects each refusal with its line number and the cell, so the sender can fix the export. Nothing reaches the totals that the parser did not understand.
The program behind the figure has two strict parsers, parse_inr_amount() and parse_ddmmyyyy(), and a third
function that guesses, trying month first and then day first:
"""Strict parsers for amounts in Indian digit grouping and for day-first dates, and what guessing per row does."""
import re
from datetime import datetime
GROUPED = re.compile(r"(\d{1,2}(?:,\d{2})*,\d{3}|\d{1,3})(?:\.(\d{2}))?")
PLAIN = re.compile(r"(\d+)(?:\.(\d{2}))?")
def parse_inr_amount(text):
"""Whole paise from 1,23,456.50 or 123456.50, with an optional rupee sign; anything else is refused."""
t = text.strip().removeprefix("₹").strip()
m = GROUPED.fullmatch(t) or PLAIN.fullmatch(t)
if not m:
raise ValueError(f"not an amount in Indian grouping: {text!r}")
return int(m.group(1).replace(",", "")) * 100 + int(m.group(2) or 0)
def parse_ddmmyyyy(text):
"""A date written DD/MM/YYYY, day first, as the bank's documentation says; anything else is refused."""
if not re.fullmatch(r"\d{2}/\d{2}/\d{4}", text):
raise ValueError(f"not a DD/MM/YYYY date: {text!r}")
return datetime.strptime(text, "%d/%m/%Y").date()
def guess_date(text):
"""What a parser that guesses does: month first, and day first only when month first fails."""
for pattern in ("%m/%d/%Y", "%d/%m/%Y"):
try:
return datetime.strptime(text, pattern).date()
except ValueError:
pass
raise ValueError(f"no date: {text!r}")
for text in ["1,23,456.50", "₹ 12,34,56,789.50", "2500.00", "456", "123,456.50", "1,2,3", "12.5"]:
try:
print(f"{text!r:<21} {parse_inr_amount(text)} paise")
except ValueError as e:
print(f"{text!r:<21} refused: {e}")
print(f"{'date':<12} {'day first':<12} guessed")
for text in ["04/10/2026", "13/10/2026", "31/10/2026", "01/11/2026"]:
print(f"{text:<12} {parse_ddmmyyyy(text).isoformat():<12} {guess_date(text).isoformat()}")
for text in ["2026-10-04", "4/10/2026"]:
try:
parse_ddmmyyyy(text)
except ValueError as e:
print("refused:", e) Output
'1,23,456.50' 12345650 paise '₹ 12,34,56,789.50' 12345678950 paise '2500.00' 250000 paise '456' 45600 paise '123,456.50' refused: not an amount in Indian grouping: '123,456.50' '1,2,3' refused: not an amount in Indian grouping: '1,2,3' '12.5' refused: not an amount in Indian grouping: '12.5' date day first guessed 04/10/2026 2026-10-04 2026-04-10 13/10/2026 2026-10-13 2026-10-13 31/10/2026 2026-10-31 2026-10-31 01/11/2026 2026-11-01 2026-01-11 refused: not a DD/MM/YYYY date: '2026-10-04' refused: not a DD/MM/YYYY date: '4/10/2026'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 parse_amounts.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The strict parsers accepted every amount in Indian grouping or none, and refused 123,456.50, 1,2,3 and 12.5 with
the cell in the message. The dates show why guessing is worse than refusing: the guesser read 13/10/2026 and
31/10/2026 correctly, because no month 13 or 31 exists, but it read 04/10/2026 as 2026-04-10 and 01/11/2026 as
2026-01-11. One column now holds dates in two orders, and nothing in the result says which rows are wrong. The strict
parser reads every date of the column one way, and refuses 2026-10-04 and 4/10/2026 because neither is the
documented form.
Checkpoint
Check: Indian amounts and day-first dates
Delimiter and header detection are guesses, so check what they return
When nobody tells the reader the dialect, it can guess it from a sample of the text, which is called
sniffing (CSV): Guessing a CSV file's delimiter, and whether it has a header, from a sample of its text, as Python's csv.Sniffer does. It is a heuristic that can pick a wrong character, so its answer is checked against the header and the rows.. Python’s csv.Sniffer returns a dialect from a sample, and its
has_header() guesses whether the first row is a header: the documentation calls it a rough heuristic that gives
false positives and false negatives. Step through how a careful reader uses those guesses:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A documented file. The price list comes with a description: semicolons, a header item;price;qty. The reader takes the delimiter from it and does not sniff at all.
- The header and the rows. The header is exactly item, price and qty, and both rows have three fields, so the file is read: Pen at 12,50 and Pad at 7,25, decimal commas kept as text for the amount parser.
- No description. The second file has the same prices without a header and without a description, so the reader can only guess. Sniffer picks the comma, the decimal comma of every price.
- The check catches it. With commas, the first row would read Pen;12 and 50;3, and there is no header to compare. A reader that requires a known header stops here and asks for the dialect instead of loading wrong prices.
- Not a table at all. The third file is a note, two sentences. Sniffer still returns a delimiter, the letter f. A guess is always an answer, which is why every guess needs the header and row checks after it.
- Guess, then prove. Pass the delimiter when the sender documents it. When you must sniff, give Sniffer the candidates, such as ";,|" and a tab, and accept the result only after the header and every row fit.
The program runs Sniffer on the three samples of the figure and has_header() on two small files, then reads the
price list with the delimiter it was told and checks the header and every row:
"""Python's csv.Sniffer on three samples and has_header on two, then a reader that is told the dialect and checks it."""
import csv
import io
SAMPLES = {
"semicolons, with a header": "item;price;qty\r\nPen;12,50;3\r\nPad;7,25;10\r\n",
"semicolons and decimal commas, no header": "Pen;12,50;3\r\nPad;7,25;10\r\nInk;3,10;2\r\n",
"a note, not a table": "Thank you for banking with us.\r\nPlease keep this statement safe.\r\n",
}
sniffer = csv.Sniffer()
for name, sample in SAMPLES.items():
try:
print(f"{name}: delimiter {sniffer.sniff(sample).delimiter!r}")
except csv.Error as e:
print(f"{name}: csv.Error: {e}")
print("has_header, id,name / 1,Asha / 2,Ravi: ", sniffer.has_header("id,name\r\n1,Asha\r\n2,Ravi\r\n"))
print("has_header, id,name / 1,Asha / 2,Ravindra:", sniffer.has_header("id,name\r\n1,Asha\r\n2,Ravindra\r\n"))
def read_declared(text, delimiter, header):
"""The rows of `text`, read with the delimiter the sender documented, after checking the header and every row."""
reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)
found = next(reader)
if found != header:
raise ValueError(f"line 1: expected the header {header}, found {found}")
rows = []
for fields in reader:
if len(fields) != len(header):
raise ValueError(f"line {reader.line_num}: {len(fields)} fields, the header has {len(header)}")
rows.append(fields)
return rows
PRICES = SAMPLES["semicolons, with a header"]
print("told ';':", read_declared(PRICES, ";", ["item", "price", "qty"]))
try:
read_declared(PRICES, ",", ["item", "price", "qty"])
except ValueError as e:
print("told ',':", e)
data = "id,branch\r\n1,Café Pune – main\r\n".encode("cp1252")
try:
data.decode("utf-8")
except UnicodeDecodeError as e:
print(f"as UTF-8: {e.reason} at byte {e.start}, {data[e.start]:#04x}")
print("as the documented cp1252:", data.decode("cp1252").splitlines()[1]) Output
semicolons, with a header: delimiter ';' semicolons and decimal commas, no header: delimiter ',' a note, not a table: delimiter 'f' has_header, id,name / 1,Asha / 2,Ravi: False has_header, id,name / 1,Asha / 2,Ravindra: True told ';': [['Pen', '12,50', '3'], ['Pad', '7,25', '10']] told ',': line 1: expected the header ['item', 'price', 'qty'], found ['item;price;qty'] as UTF-8: invalid continuation byte at byte 16, 0xe9 as the documented cp1252: 1,Café Pune – main
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sniff.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Sniffer was right about the price list with a header. Without the header, it chose the comma, the decimal separator of
every price. Given a note that is not a table at all, it still answered, with the letter f. has_header() said
False for id,name / 1,Asha / 2,Ravi, which has a header: Asha and Ravi are as long as the word name, so only
one of the two columns looks like it has a header, and one of two is not a majority. Change Ravi to Ravindra and it
says True. Told ;, the reader returned both rows; told ,, its header check stopped at line 1, which is where a
wrong guess should stop.
Limit the guess
Press Edit, change sniffer.sniff(sample) to sniffer.sniff(sample, delimiters=";,|\t") and run it again. The note
now raises csv.Error: Could not determine delimiter, but the price list without a header still comes back with the
comma.
So take the delimiter, the header and the encoding from the sender’s documentation and pass them explicitly. When you must guess, limit the candidates and accept the guess only after the header and every row fit.
The encoding is a guess too. The last lines of the output decode a branch name written in Windows-1252: as UTF-8 it
fails at byte 16, 0xe9, the é, and decoded with the encoding the sender documented it reads Café Pune – main.
Decode strictly, with utf-8-sig for files that may start with a byte-order mark, and switch to a legacy encoding only
when the sender says so. errors="ignore" or "replace" would read on and change the text instead.
Checkpoint
Check: Checking what detection returns
Practise: clean a bank statement export
The exercise puts the lesson together: it decodes an export with a byte-order mark, keeps the narration in any script and the reference as text, flags a reference a spreadsheet has damaged, and turns day-first dates and Indian amounts into ISO dates and whole paise, refusing every other form with its line:
Exercise · Medium · Python
Clean a bank statement export into typed records
Write clean_statement(data) in statement.py. It takes the bytes of a bank statement export and returns one dict per transaction, with every value typed by a rule instead of a guess. The export is UTF-8, may start with a byte-order mark, and has the header Txn Date,Narration,Ref No,Withdrawal,Deposit,Balance. Each record becomes:
date: theTxn Date, which is always day first (DD/MM/YYYY, two digits for the day and the month), as an ISO string such as2026-10-04;narration: the text exactly as written, in any script;refandref_ok: theRef Noas text, and whether it is still all digits (a spreadsheet may have turned it into something like4.00123456789012E+17);amount_paise: minus the withdrawal or plus the deposit, in whole paise; exactly one of the two is filled in;balance_paise: the balance in whole paise.
Amounts have two decimals and either no grouping (2500.00) or Indian grouping (1,23,456.50); anything else, such as 123,456.50, is refused. Every refusal is a ValueError whose message starts with the line, line 3: …, counting the header as line 1.
Starter code · statement.py
import csv
import io
def clean_statement(data):
"""One typed dict per transaction of a bank statement export (bytes)."""
return list(csv.DictReader(io.StringIO(data.decode("utf-8")))) The sample tests · test_statement.py
import codecs
from statement import clean_statement
HEADER = "Txn Date,Narration,Ref No,Withdrawal,Deposit,Balance\r\n"
def export(*lines, bom=True):
"""The bytes of an export with the given records, as the bank writes it."""
text = HEADER + "".join(line + "\r\n" for line in lines)
return (codecs.BOM_UTF8 if bom else b"") + text.encode("utf-8")
def error_of(data):
"""The message of the ValueError clean_statement(data) raises, or None."""
try:
clean_statement(data)
except ValueError as e:
return str(e)
return None
def test_typed_records():
"""types the date, the amounts and the balance of each record"""
data = export(
'04/10/2026,NEFT Asha Traders,400123456789012345,"1,23,456.50",,"3,76,543.50"',
"13/10/2026,Cash deposit,400123456789012340,,2500.00,\"3,79,043.50\"",
)
assert clean_statement(data) == [
{"date": "2026-10-04", "narration": "NEFT Asha Traders", "ref": "400123456789012345", "ref_ok": True,
"amount_paise": -12345650, "balance_paise": 37654350},
{"date": "2026-10-13", "narration": "Cash deposit", "ref": "400123456789012340", "ref_ok": True,
"amount_paise": 250000, "balance_paise": 37904350},
]
def test_without_bom_and_in_hindi():
"""reads an export without a BOM and keeps a narration in Hindi exactly"""
records = clean_statement(export('31/10/2026,चाय और नाश्ता,400123456789012399,450.00,,"3,78,593.50"', bom=False))
assert records[0]["narration"] == "चाय और नाश्ता"
assert records[0]["amount_paise"] == -45000
def test_damaged_reference():
"""keeps a reference a spreadsheet damaged, and flags it"""
records = clean_statement(export('01/11/2026,Card refund,4.00123456789012E+17,,99.00,"3,78,692.50"'))
assert records[0]["ref"] == "4.00123456789012E+17"
assert records[0]["ref_ok"] is False
def test_western_grouping_refused():
"""refuses an amount grouped in threes, with its line"""
message = error_of(export("04/10/2026,Rent,400123456789012345,\"123,456.50\",,\"2,53,087.00\""))
assert message is not None and message.startswith("line 2:")
def test_month_first_date_refused():
"""refuses a date that is not day first, with its line"""
message = error_of(export(
"04/10/2026,Tea,400123456789012345,45.00,,1000.00",
"10/13/2026,Coffee,400123456789012346,55.00,,945.00",
))
assert message is not None and message.startswith("line 3:")
def test_one_amount_per_record():
"""refuses a record with both amounts, or with neither"""
assert error_of(export("04/10/2026,Odd,400123456789012345,10.00,20.00,1000.00")).startswith("line 2:")
assert error_of(export("04/10/2026,Empty,400123456789012345,,,1000.00")).startswith("line 2:") A hint
Decode with data.decode("utf-8-sig"), read with csv.DictReader(io.StringIO(text, newline="")) and take each line number from the reader's line_num. Write two small helpers first, one for a day-first date and one for an amount in paise, each of which raises ValueError for anything it does not expect, and build every record from them.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then try the questions on spreadsheets, declared types, Indian formats and detection. Your weakest idea comes up first, and every choice, right or wrong, comes with the reason from the lesson’s own runs:
Practice that adapts to you
Practise this lesson
12 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): why should account numbers, phone numbers and PIN codes stay text when you read a CSV file?
Because they are identifiers, not quantities: nobody adds two phone numbers. As text they keep every character. As
numbers they lose some: a leading zero disappears, so 0484 becomes 484, and a long reference loses digits, because
a spreadsheet keeps 15 significant digits and a JavaScript number is exact only up to 9,007,199,254,740,991. In the
lesson’s run, two different 18-digit references became the same JavaScript number, so a lookup would match the wrong
transaction. Keep them as text from the reader onwards, compare them as text, and if you must check their form, use a
pattern such as exactly 10 digits for a mobile number. Convert a column to a number only when you will calculate with
it, as with an amount.
Two harder questions follow, about dates that moved after a second bank was added and about references that a partner’s file changed. Decide what you would check first, then open each first step:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
To accept a second bank's statements, someone replaced a strict date parser with one that "understands any format". Since then about 3 % of transactions show dates in the future, and some October payments appear in April. What is going on, and how do you fix the parser and the data already loaded?
-
Fresher to mid
A partner's daily CSV now arrives with values like 4.00123456789012E+17 in its reference column, and none of them match your records. The partner says nothing changed in their system. What happened, can you repair the file, and what do you change in the process?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Which four kinds of value does a spreadsheet change when it opens a CSV file?
Long numbers keep only 15 significant digits, leading zeros vanish, digits around an E turn into powers of ten, and some codes become dates.
-
Why should account numbers, phone numbers and PIN codes stay text?
They are identifiers, not quantities. As numbers they lose leading zeros, and long ones lose digits; two 18-digit references even became one JavaScript number.
-
What does JavaScript's Number() do with an empty CSV field?
Returns 0, which looks like a real amount. It also accepts 0x1A as 26, so declare each column's type and handle blanks on purpose.
-
How are amounts grouped in Indian formatting?
The last three digits, then pairs: 1,00,000 is one lakh and 1,00,00,000 one crore. Parse such amounts with a strict parser that checks the whole form.
-
Why must day-first dates be parsed with the order stated?
A row-by-row guess mixes two date orders in one column without an error. Tell the parser the order and check that each day exists.
-
Can you rely on csv.Sniffer and has_header()?
Not alone: they are heuristics that can return a wrong delimiter, even a letter. Pass the documented dialect and encoding, and accept a guess only if the header and every row fit.
Key takeaways
- A spreadsheet’s default conversions remove leading zeros, keep only 15 significant digits, read digits around E as powers of ten and turn some codes into dates, so keep such columns text from the moment the file is opened.
- Type inference in code makes the same losses: Python’s guess drops zeros and mixes types in a column, and
JavaScript’s
Number()loses digits above the safe integer limit and turns empty text into 0; declare one type per column instead. - Amounts formatted for India are grouped by three and then by two, and their dates put the day first; parse both with strict parsers that check the form first and refuse anything else, because guessing per row mixes two date orders in one column.
csv.Snifferandhas_header()are heuristics that can return a wrong delimiter, a letter or a wrong answer; pass the documented dialect and encoding, and accept a guess only after the header and every row fit.
Terms in this lesson
- digit grouping
- The separators a locale puts between groups of digits in a number. English in the United States writes 123,456,789.5; English in India groups by three and then by two, as 12,34,56,789.5. A parser removes them by the documented rule.
- sniffing (CSV)
- Guessing a CSV file's delimiter, and whether it has a header, from a sample of its text, as Python's csv.Sniffer does. It is a heuristic that can pick a wrong character, so its answer is checked against the header and the rows.
- type inference
- Guessing a column's type from the text of its values, as the automatic typing of spreadsheets and CSV libraries does. It changes values without an error: 0484 becomes 484, and a long reference loses digits.
References
- Excel specifications and limits (Microsoft)
- Keeping leading zeros and large numbers (Microsoft)
- CLDR charts: number formatting patterns (Unicode Consortium)
- Intl.NumberFormat (Mozilla)
- ECMAScript 2026 Language Specification: StringToNumber (Ecma International)
- csv: CSV File Reading and Writing (Python 3.14), Sniffer (Python Software Foundation)
- datetime: format codes (Python 3.14) (Python Software Foundation)
- Papa Parse documentation: config options (Papa Parse)
- CSV Parse: options (Adaltas (Node.js CSV project))
- RFC 4180: Common Format and MIME Type for Comma-Separated Values (CSV) Files (IETF)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress