Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, quickjs 0.32.0, Python 3.14.8, Pyodide 314.0.7, Ruby 4.0.7 and sql.js 1.14.2
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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 a spreadsheet changes in a bank export Python · sheet/at_risk.py
"""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

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

  1. Question 1 of 2 How many significant digits of a number does Excel keep, by its published specifications?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 15 digits

    Excel's specifications give a number precision of 15 digits. A reference of 18 digits keeps 15 of them, and the last three become zeros, as at_risk.py showed for 400123456789012345.

  2. Question 2 of 2 A CSV file holds the STD code 0484. What does a spreadsheet hold in that cell after opening the file with its default settings?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 484

    0484 looks like a number, so the spreadsheet stores the number 484. The zero is not hidden by a format; it is gone from the cell.

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():

One export as text, guessed and declared

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

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

  1. Question 1 of 2 What does this line print in Node.js?

    Read the code, then choose one answer.

    console.log(Number("400123456789012345") === Number("400123456789012340"));
    Show the answer: Question 1 of 2

    Answer: true

    A JavaScript number is exact only up to 9007199254740991. Two different 18-digit references become one number, so a lookup would match the wrong transaction. Keep identifiers as text.

  2. Question 2 of 2 In the declared reading of the export, an empty withdrawal becomes None. Why not 0, as Number("") gives in JavaScript?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Because an empty cell means there is no withdrawal in that row, which is not the same as a withdrawal of zero

    A converter declared for the column decides what empty means, once, for every row. Number("") makes it 0 silently, which turns a missing value into a real-looking amount.

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:

One amount and one day in two locales JavaScript · india/format_in.mjs
// 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:

Interactive · stage by stage Four amounts through a parser that refuses to guess Step through four amounts from bank exports. Each step says what the parser checks and what it decides.
Four amounts through a parser that refuses to guess, step 7 of 7. Showing The cell as text (12.5), Drop the currency sign (sign and space dropped), Check the grouping (12: no grouping, fits), Check the decimals (.5: refused), Whole paise (12345678950 paise). In focus: Whole paise.The cell as text12.5Drop the currency signsign and space droppedCheck the grouping12: no grouping, fitsCheck the decimals.5: refusedWhole paise12345678950 paise

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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, and a parser that guesses dates Python · india/parse_amounts.py
"""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

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

  1. Question 1 of 2 Which of these does parse_inr_amount() accept?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • ₹ 12,34,56,789.50
    • 1,23,456.50
    • 2500.00

    The parser checks the whole form before converting: an optional sign, digits in Indian grouping or none, and either no decimals or exactly two. Everything else is refused with the cell in the message.

  2. Question 2 of 2 The file writes every date day first. guess_date() tries month first, then day first. What does the call below return?

    Read the code, then choose one answer.

    guess_date("01/11/2026")
    Show the answer: Question 2 of 2

    Answer: Day 11 of month 1, which is wrong

    A guess per row is right only where month first is impossible, as for 13/10. Elsewhere it silently swaps day and month, so one column ends up with two orders and no sign of which rows are wrong.

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:

Interactive · stage by stage Told the dialect, or guessing it, then checking Step through three files. Each step says where the dialect came from and which check accepts or stops the file.
Told the dialect, or guessing it, then checking, step 6 of 6. Showing Dialect documented?, Sniff a sample (guessed 'f'), Check the header, Check every row, Read the rows. In focus: Dialect documented?, Check every row.yes: use itDialect documented?the sender's descriptionSniff a sampleguessed 'f'Check the headerthe names you expectCheck every rowfields match the headerRead the rowswith that dialect only

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Sniffing, then reading with a documented dialect Python · detect/sniff.py
"""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

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.

Text Encoding Converter & Mojibake Fixer Open an export whose accents look wrong, find its encoding and save it as UTF-8 before you parse it.

Checkpoint

Check: Checking what detection returns

  1. Question 1 of 2 Sniffer is given Pen;12,50;3, Pad;7,25;10 and Ink;3,10;2, one per line, with no header. Which delimiter did it return?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A comma

    A guess is only as good as the sample. Here the decimal commas looked as regular as the semicolons, and the reader would have split every price in two. A header check after the guess stops such a file.

  2. Question 2 of 2 What does the Python documentation say about Sniffer.has_header()?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is a rough heuristic that may give false positives and false negatives

    has_header() votes per column: numbers below a text header, or strings of another length than the header, count as signs of a header. With short names such as Asha and Ravi it can be wrong, so pass header knowledge explicitly.

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: the Txn Date, which is always day first (DD/MM/YYYY, two digits for the day and the month), as an ISO string such as 2026-10-04;
  • narration: the text exactly as written, in any script;
  • ref and ref_ok: the Ref No as text, and whether it is still all digits (a spreadsheet may have turned it into something like 4.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.

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.

  1. Question 1 of 12 Which of these does Microsoft list among the conversions Excel makes by default when data comes in?

    Choose every answer that is right.

    Show the answer: Question 1 of 12

    Answer:

    • Removing leading zeros from numerical text
    • Reading digits around the letter E as scientific notation
    • Cutting numerical data to 15 digits of precision

    Microsoft lists four default conversions: leading zeros removed, 15 digits of precision, digits around E as scientific notation, and some strings of letters and numbers turned into dates.

  2. Question 2 of 12 Why did the references 400123456789012345 and 400123456789012340 become the same value in the spreadsheet model?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Only 15 digits are kept, and both begin with the same 15 digits

    Any two identifiers that agree in their first 15 digits collide. For 18-digit references that is every group of a thousand neighbouring numbers.

  3. Question 3 of 12 Finance must open a daily file of 18-digit references in a spreadsheet, and match them against the bank later. What do you send?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: An .xlsx whose reference column is stored as text, or a CSV with instructions to import that column as Text

    Microsoft's advice is a text format set before the values arrive, or an import that sets the column's type. A workbook with text cells does both for the people who open it.

  4. Question 4 of 12 What does this print?

    Read the code, then choose one answer.

    print(int("09876543210"))
    Show the answer: Question 4 of 12

    Answer: 9876543210

    The guess of three_ways.py turned the mobile number into 9876543210. A phone number is an identifier: keep it as text and check its form with a pattern instead.

  5. Question 5 of 12 What does this print in Node.js?

    Read the code, then choose one answer.

    console.log(Number(""), Number("  "), Number("0x1A"));
    Show the answer: Question 5 of 12

    Answer: 0 0 26

    Number() is a converter for JavaScript source text, not for data. A guess built on it turns empty cells into 0 and accepts forms no bank export uses.

  6. Question 6 of 12 You declare the types of an export with the columns mobile, ref_no, amount, txn_date and narration. Which plan is right?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: mobile and ref_no text, amount whole paise from its text, txn_date by its documented order, narration text

    One converter per column, chosen from the sender's description: text for identifiers, whole minor units for money, a declared format for dates. The prerequisite lesson on JSON numbers explains why money is kept in paise.

  7. Question 7 of 12 What decimal number pattern does Unicode CLDR give for English in India?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: #,##,##0.###

    CLDR's en_IN and hi locales share the pattern #,##,##0.###. Node's Intl.NumberFormat("en-IN") follows it, which is how format_in.mjs printed 12,34,56,789.5.

  8. Question 8 of 12 Why does parse_inr_amount() refuse 123,456.50 instead of reading it as 123456.50?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Because the file documents Indian grouping, so groups of three mean a different writer or an error to report

    A strict parser accepts exactly the documented forms. A file whose amounts suddenly come grouped by threes has changed, and the sender should hear of it before any total is wrong.

  9. Question 9 of 12 An imported column of day-first dates was read by a guessing parser. Rows whose day was 13 or more are right. What should you assume about the others?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Every row whose day was 12 or less may have day and month swapped, so re-read the whole column with the documented order

    The guesser read 01/11 as day 11 of month 1 and 04/10 as day 10 of month 4. Fix the parser, then load the column again from the source text, never by swapping values in the database.

  10. Question 10 of 12 Sniffer is given two sentences of a bank note, which are not a table at all. What happened in the lesson's run?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: It returned the letter f as the delimiter

    Sniffer finds the character that splits the sample most regularly, whatever that character is. It does not know whether the text is a table at all.

  11. Question 11 of 12 What does the delimiters argument do here?

    Read the code, then choose one answer.

    dialect = csv.Sniffer().sniff(sample, delimiters=";,|\t")
    Show the answer: Question 11 of 12

    Answer: It limits the guess to a semicolon, a comma, a pipe or a tab

    With candidates, a note can no longer produce the letter f, but the decimal-comma file can still produce a comma. Accept the result only after the header and the field counts fit.

  12. Question 12 of 12 An export fails to decode as UTF-8 with invalid continuation byte at 0xe9, and its sender says it is Windows-1252. What do you do?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Decode it as cp1252, as documented, and convert it to UTF-8 for the rest of the pipeline

    Decode strictly, with the encoding the sender documents, and refuse to guess silently. utf-8-sig covers UTF-8 with or without a BOM; legacy code pages are used only when they are named.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.Sniffer and has_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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.