Collect prices from more than one country and you will meet the same number written a dozen ways. A German site shows 1.234,56 €. A French one shows 1 234,56 €, with a space you may not be able to see. A Swiss one shows CHF 1’234.50. An Indian one groups digits as 12,34,567. And a date like 03/04/2026 means the 4th of March in the United States and the 3rd of April almost everywhere else.
Get any of these wrong and the error is rarely obvious: a price off by a factor of a thousand, a date a month out, a currency confused with another that shares its symbol. This guide covers the formats you will meet, the rules that decide them, and parsing code that refuses to guess when a value does not fit.
Key takeaways
- Parse every value with the locale of the page it came from. The same characters mean different numbers in different locales.
- Do not strip separators and hope. Decide which symbol is the decimal separator from the locale, and reject values that do not fit.
- Treat currency symbols as ambiguous. “$10.00” is Canadian dollars on a Canadian page, pesos on a Mexican page and Australian dollars on an Australian page. Store ISO 4217 codes.
- Store money in minor units with the right precision. The yen has no decimal places; the Bahraini and Kuwaiti dinars have three.
- Numeric dates need a locale, or better, a machine-readable source. Prefer ISO 8601 from structured data where the page provides it.
Where the rules come from
Locale conventions are not folklore. They are published in the Unicode Common Locale Data Repository, CLDR, which operating systems, browsers and most internationalisation libraries use. The examples below come straight from CLDR data, via the Python library Babel, formatting the number 1234567.891 and the amount 1,234.50 euros:
| Locale | Decimal | Group | 1234567.891 | €1,234.50 |
|---|---|---|---|---|
| English, United States | . | , | 1,234,567.891 | €1,234.50 |
| German, Germany | , | . | 1.234.567,891 | 1.234,50 € |
| French, France | , | narrow no-break space | 1 234 567,891 | 1 234,50 € |
| German, Switzerland | . | ’ | 1’234’567.891 | EUR 1’234.50 |
| English, India | . | , | 12,34,567.891 | €1,234.50 |
| Portuguese, Brazil | , | . | 1.234.567,891 | € 1.234,50 |
Three details catch people out. French groups thousands with a narrow no-break space, a character that looks like a space and is not one. Swiss German uses an apostrophe-like character. And Indian English groups the first three digits and then pairs, so 1,234,567 is written 12,34,567.
Parse with the locale, and refuse what does not fit
The tempting approach is to remove everything except digits and one separator, then guess which separator is the decimal point. It works until it meets “1.234”, which is one thousand two hundred and thirty-four in Germany and one point two three four in the United States. No amount of cleverness recovers the right answer from the string alone. The locale does.
import re
from babel.dates import parse_date
from babel.numbers import NumberFormatError, get_currency_precision, get_group_symbol, parse_decimal
# Keep digits, separators and the space variants sites use between thousands.
NUMBER_CHARS = re.compile("[^0-9.,'\u2019 \u00a0\u2009\u202f-]")
SPACES = re.compile("[ \u00a0\u2009\u202f]")
def parse_amount(text, locale):
"""Parse a displayed amount using the page's locale. Refuses input that does not fit it."""
number = NUMBER_CHARS.sub("", text).strip()
group = get_group_symbol(locale)
if SPACES.fullmatch(group):
# Sites mix ordinary, no-break and narrow no-break spaces; use the locale's own.
number = SPACES.sub(group, number)
try:
return parse_decimal(number, locale=locale, strict=True)
except NumberFormatError as e:
raise ValueError(f"{text!r} does not parse as a {locale} number: {e}") from None
def to_minor_units(amount, currency):
"""Store money as an integer in the currency's smallest unit (cents, yen, fils)."""
return int((amount * 10 ** get_currency_precision(currency)).to_integral_value())
def parse_display_date(text, locale):
"""Numeric dates are ambiguous without a locale: 03/04/2026 is March or April."""
return parse_date(text.strip(), locale=locale)
Tested on real-world formats:
| Displayed | Locale | Parsed |
|---|---|---|
| $1,234.56 | English, United States | 1234.56 |
| 1.234,56 € | German, Germany | 1234.56 |
| 1 234,56 € (any of three space characters) | French, France | 1234.56 |
| CHF 1’234.50 | German, Switzerland | 1234.50 |
| ₹12,34,567.00 | English, India | 1234567.00 |
| R$ 1.234,56 | Portuguese, Brazil | 1234.56 |
| 1.234,56 € | English, United States | Refused |
| 9,99 | English, United States | Refused |
The last two rows are the point. A German-formatted price on a page you believed was American is a signal that something is wrong: the wrong locale variant was served, the page changed, or your configuration is off. An error you can see beats a price quietly off by a factor of a thousand.
The space handling earned its place in testing. Our first version refused a French price written with an ordinary space, because CLDR’s French separator is the narrow no-break space. Real sites mix all three space characters, so the code now maps any of them to the locale’s own before parsing.
Currency: never trust the symbol alone
Many currencies share a symbol. CLDR formats ten units of the local currency as “$10.00” in Canada, Mexico and Australia alike, and shows US dollars as “US$10.00” on a Canadian page. A scraper that maps ”$” to USD will be wrong on a large share of the world’s sites.
Take the currency from something unambiguous: an ISO 4217 code in the page’s structured data, a currency selector’s value, or the known default currency of the site and market you collected from. Store the three-letter code with every price, and treat a symbol-only price with no other evidence as incomplete rather than guessing.
Minor units and precision
Once parsed, store money as integers in the currency’s smallest unit, because floating-point arithmetic on money accumulates rounding errors. The number of decimal places is not always two:
| Currency | Decimal places | 1,234 displayed becomes |
|---|---|---|
| Euro, US dollar | 2 | 123400 |
| Japanese yen, Korean won, Chilean peso | 0 | 1234 |
| Bahraini dinar, Kuwaiti dinar | 3 | 1234000 |
Record the unit alongside the value. A pipeline that mixes major and minor units, as in the price that arrived as 10000 for a $100 product described in schema drift in scraped data, produces errors of exactly a factor of a hundred that look entirely plausible.
Dates: prefer machine-readable sources
The same numeric date parses differently by locale:
| Displayed | Locale | Parsed |
|---|---|---|
| 03/04/2026 | English, United States | 4 March 2026 |
| 03/04/2026 | English, United Kingdom | 3 April 2026 |
| 03/04/2026 | German, Germany | 3 April 2026 |
Where you can, avoid parsing displayed dates at all. Many pages publish machine-readable dates in structured data or in the datetime attribute of a <time> element, usually in ISO 8601, which is unambiguous; extracting them is covered in stop parsing HTML. When you must parse a displayed date, use the page’s locale explicitly, store the result in ISO 8601 with a time zone, and flag values where both day and month are twelve or below for sites whose locale you have not confirmed.
Get the locale right in the first place
Every rule above depends on knowing which locale a page used, and that is partly a collection decision. The same product page can render in a different language, currency and number format depending on the visitor’s country, which is why a price collected through an exit in Germany and one collected through an exit in the United States are not directly comparable until both are normalised. Record the market each value was collected from, and keep sessions consistent in language and region, as covered in matching residential proxy geo, timezone and locale.
The bottom line
Numbers, prices and dates look universal and are not. The same characters mean different values in different locales, symbols map to many currencies, precision varies by currency, and numeric dates are ambiguous by design.
Parse every value with the locale it was written in, refuse what does not fit instead of guessing, store currency as an ISO code and money in minor units with the right precision, and prefer machine-readable dates wherever the page provides them. Each of those rules turns a silent, plausible error into a visible one.
Sources and references
- Unicode Consortium, Common Locale Data Repository (CLDR).
- Babel, Python internationalisation library, version 2.18.0, used for the examples above.
- ISO 4217 currency codes and ISO 8601 date and time format.