---
title: "Normalise Prices Across Currencies and Sources in Python"
description: "Parse '162,90 €' and $89.99 into Decimals, tag each price with its currency, convert with a dated ECB reference rate, and keep the original value for audit."
url: https://perceptlayer.com/blog/normalize-prices-multiple-currencies
---

[Blog](https://perceptlayer.com/blog) / [Data engineering](https://perceptlayer.com/blog/category/data-engineering)

# Normalise Prices Across Currencies and Sources in Python

14 Aug 2026 Perceptlayer 7 min read

To normalise prices across currencies in Python, store every price as a `Decimal` with its ISO currency code, convert it with a reference rate for the date you collected it, and keep the original amount, currency, rate and rate date next to the converted figure. This guide does that for three recorded responses in three currencies: Idealo in euros, Walmart in US dollars and Mercado Libre in Argentine pesos, with a parser for strings like `162,90 €`.

## Key takeaways

-   All three recorded responses carry a numeric `price` and a `currency` code ( `EUR`, `USD`, `ARS`), so parse strings only where you must, such as Idealo's `shipping_costs` of `4,99 €`.
-   Walmart's recorded prices come back as both floats ( `89.99`) and integers ( `79`); convert through `Decimal(str(x))` so every amount has one type and no float error.
-   The ECB publishes euro reference rates for 29 currencies around 16:00 CET on working days; the Argentine peso isn't among them, so ARS needs a different source, such as the BCRA's reference rate.
-   Store `amount`, `currency`, `rate`, `rate_date` and the converted amount on every row; a conversion you can't trace can't be corrected.
-   German consumer prices include VAT by law and US shelf prices usually don't include sales tax, so a converted price comparison across those two still isn't like for like.

## What do the raw prices look like?

Each source returns a number and a currency, plus a formatted string in some cases. That's the best starting point: parse the number, not the display text. The excerpts below are trimmed with `…`. The [Idealo search endpoint](https://perceptlayer.com/apis/idealo/search) returned German-formatted strings next to the number:

```json
{
  "results": [
    {
      "id": "203597427",
      "title": "Ninja AF500",
      "price": 162.9,
      "price_formatted": "162,90 €",
      "currency": "EUR",
      "offer_count": 80,
      "cheapest_offer": { "shop_name": "alza.de", "shipping_costs": "4,99 €", "shipping_is_free": false, … },
      …
    },
    …
  ]
}
```

The [Walmart search endpoint](https://perceptlayer.com/apis/walmart/search) returns a bare number, and the type changes with the value:

```json
{
  "data": {
    "results": [
      { "us_item_id": "17268119743", "title": "Instant Pot 4QT VORTEX 6-in-1 Mini Air Fryer, …", "price": 89.99, "currency": "USD", … },
      …,
      { "us_item_id": "14140920286", "title": "Thyme & Table 6 Quart Ceramic-Coated Air Fryer, Sand White", "price": 79, "currency": "USD", … }
    ]
  }
}
```

The [Mercado Libre category products endpoint](https://perceptlayer.com/apis/mercadolibre/category-products) returns whole pesos, with the earlier price and a discount label:

```json
{
  "data": {
    "site": "MLA",
    "products": [
      { "item_id": "MLA1829410667", "price": 9208, "previous_price": 10963, "currency": "ARS", "discount_label": "16% OFF", … },
      …
    ]
  }
}
```

| Source | Price field | Type in the recording | Currency field | Formatted text |
| --- | --- | --- | --- | --- |
| Idealo | `price` | float | `currency` ( `EUR`) | `price_formatted`, `shipping_costs` |
| Walmart | `price` | float or integer | `currency` ( `USD`) | none |
| Mercado Libre | `price`, `previous_price` | integer | `currency` ( `ARS`) | `discount_label` |

## How do you parse price strings like "162,90 €"?

Find the decimal separator from the string itself, strip the thousands separators, and return a `Decimal` with the currency the symbol implies. German, French and many other locales write a comma for decimals and a dot for thousands; US and UK formats do the opposite. A string with both marks settles it: whichever comes last is the decimal separator.

```python
"""Normalise prices from several sources to one currency, keeping the original for audit."""
import re
from decimal import ROUND_HALF_EVEN, Decimal

CENT = Decimal("0.01")

def parse_price(text):
    """'162,90 €' -> (Decimal('162.90'), 'EUR'); '$1,234.56' -> (Decimal('1234.56'), 'USD')."""
    symbols = {"€": "EUR", "$": "USD", "£": "GBP"}
    currency = next((c for s, c in symbols.items() if s in text), None)
    digits = re.sub(r"[^\d.,]", "", text)
    if "," in digits and "." in digits:
        decimal_sep = "," if digits.rfind(",") > digits.rfind(".") else "."
    elif "," in digits:
        decimal_sep = "," if len(digits.rsplit(",", 1)[1]) == 2 else None
    elif "." in digits:
        decimal_sep = "." if len(digits.rsplit(".", 1)[1]) == 2 else None
    else:
        decimal_sep = None
    thousands = {",", "."} - {decimal_sep}
    for t in thousands:
        digits = digits.replace(t, "")
    if decimal_sep == ",":
        digits = digits.replace(",", ".")
    return Decimal(digits), currency
```

We ran it on strings from the recordings and a few of our own. `162,90 €` and `4,99 €` parsed to 162.90 and 4.99 EUR, `1.234,56 €` and `$1,234.56` both to 1234.56, and the Idealo characteristic `2.470` (watts) to 2470 with no currency. With a single separator and three digits after it, the function treats it as a thousands separator; that's right for prices, but `1,234` in a locale that means 1.234 would be misread, so keep the source's locale in mind.

Symbols are ambiguous: `$` is used by many currencies, including the Argentine peso. Use the parser's currency only when the source gives no code, and prefer the `currency` field whenever there is one.

## How do you map each source onto one record?

Write one small function per source that returns the same fields: source, ID, title, amount as a `Decimal`, ISO currency, shipping if known, and the original value as text. `Decimal(str(x))` matters: `Decimal(89.99)` would carry the float's binary error into every later sum, while `Decimal("89.99")` is exact.

```python
def from_idealo(r):
    return {"source": "idealo", "id": r["id"], "title": r["title"],
            "amount": Decimal(str(r["price"])), "currency": r["currency"],
            "shipping": parse_price(r["cheapest_offer"]["shipping_costs"])[0]
            if r.get("cheapest_offer", {}).get("shipping_costs") else None,
            "original": r.get("price_formatted")}

def from_walmart(r):
    return {"source": "walmart", "id": r["us_item_id"], "title": r["title"],
            "amount": Decimal(str(r["price"])), "currency": r["currency"],
            "shipping": None, "original": str(r["price"])}

def from_mercadolibre(r):
    return {"source": "mercadolibre", "id": r["item_id"], "title": r["title"],
            "amount": Decimal(str(r["price"])), "currency": r["currency"],
            "shipping": None, "original": str(r["price"])}
```

Shipping is kept separate rather than added in. Idealo gives the cheapest offer's shipping; the Walmart and Mercado Libre search results don't carry a shipping amount, only, for Mercado Libre, text such as "Envío gratis" with a note that it applies to a first purchase. Comparing item prices plus shipping only where you know shipping would bias the comparison.

## Which exchange rate should you use?

A published reference rate, for the date you collected the price, from a source you name. The ECB publishes euro foreign exchange reference rates for 29 currencies, usually around 16:00 CET on working days, and says they're for information purposes only ( [European Central Bank](https://www.ecb.europa.eu/stats/policy_and_exchange_rates/euro_reference_exchange_rates/html/index.en.html), 2026). That's what price analysis needs: a consistent, citable daily rate, not the rate a bank would charge.

The ECB's list doesn't include the Argentine peso. For ARS, the Banco Central de la República Argentina publishes a wholesale reference rate against the US dollar, known by its regulation, Communication A 3500, with historical series to download ( [BCRA](https://www.bcra.gob.ar/en/reference-exchange-rate-communication-a-3500-and-monthly-average-nominal-exchange-rate-tcnpm/), 2026). Convert ARS to USD with that, then USD to EUR with the ECB rate for the same date, and record both rates.

```python
def to_eur(row, rates, rate_date):
    """rates: {(currency, date): units of currency per 1 EUR}, as the ECB publishes them."""
    out = dict(row, target="EUR", rate_date=rate_date)
    if row["currency"] == "EUR":
        out.update(rate=Decimal(1), amount_eur=row["amount"].quantize(CENT))
        return out
    rate = rates.get((row["currency"], rate_date))
    if rate is None:
        out.update(rate=None, amount_eur=None)      # no dated rate: leave it unconverted
        return out
    eur = (row["amount"] / rate).quantize(CENT, rounding=ROUND_HALF_EVEN)
    out.update(rate=rate, amount_eur=eur)
    return out
```

The function refuses to guess: a currency with no rate for that date stays unconverted, with `amount_eur` set to `None`. Load `rates` from the ECB's daily file or its data API, keyed by currency and date. Use the rate for the collection date, or the last published rate before it on weekends and holidays, and store which one you used.

## What does the normalised output look like?

We ran the three mappers and `to_eur` on the recorded responses with one rate: 1.1193 US dollars per euro, the ECB reference rate for 23 October 2026. We read that figure from the ECB's published table through search results, not from its data feed, so check it against the ECB's file before you reuse it. We had no ARS rate loaded, so the Mercado Libre rows stayed unconverted, as designed.

| Source | Product | Original | Amount | Currency | EUR |
| --- | --- | --- | --- | --- | --- |
| idealo | Ninja AF500 | `162,90 €` | 162.9 | EUR | 162.90 |
| idealo | Philips NA221/00 | `76,30 €` | 76.3 | EUR | 76.30 |
| walmart | Chefman Air Fryer 1500W, 6 Quart | `59.98` | 59.98 | USD | 53.59 |
| walmart | Thyme & Table 6 Quart Ceramic-Coated Air Fryer | `79` | 79 | USD | 70.58 |
| walmart | Instant Pot 4QT VORTEX Mini Air Fryer | `89.99` | 89.99 | USD | 80.40 |
| mercadolibre | Barra Luz Led 50cm Recargable | `9208` | 9208 | ARS | none (no rate) |

Every row keeps the original text, the parsed amount, the currency, and, in the full output, the rate and its date. If the rate turns out to be wrong, or you switch to a different rate source, you can recompute every converted figure without calling the APIs again. Load the rows into a table with those columns; [loading API JSON into Postgres](https://perceptlayer.com/blog/load-web-data-json-into-postgres) shows the pattern.

## Why is a converted price still not like for like?

Because currency is only one difference. Taxes, shipping, product and market all differ, and none of them is fixed by an exchange rate. Normalise currency first, then decide which of the others your analysis must control for.

Taxes are the largest. Germany's price indication rules require prices offered to consumers to be stated as total prices including VAT ( [PAngV § 3](https://www.gesetze-im-internet.de/pangv_2022/__3.html), 2026). US retail prices are usually shown before sales tax, which varies by state and city. So 162.90 EUR on Idealo and 53.59 EUR converted from Walmart differ by tax treatment as well as by product.

Products need matching, too: the table compares different air fryers, not one model in two markets. And in a currency with fast-moving rates, converting a month of prices at one rate misstates every day but one, which is why the code keys rates by date. For matching the same product across sources, see [deduplicating listings across sources](https://perceptlayer.com/blog/deduplicate-listings-across-sources).

## What does collecting the prices cost?

Each call to these endpoints costs 1 credit on success, and failed calls cost nothing and return `X-Credits-Used: 0`. Normalisation runs locally and costs nothing. The [credits docs](https://docs.perceptlayer.com/credits) explain billing; exchange rate files from the ECB and the BCRA are free.

| Job | Calls a day | Credits a month (30 days) |
| --- | --- | --- |
| 1 query on each of 3 sources | 3 | 90 |
| 10 queries on each of 3 sources | 30 | 900 |
| 100 queries on each of 3 sources | 300 | 9,000 |

Worked example: tracking 10 products on Idealo, Walmart and Mercado Libre daily is 10 × 3 × 30 = 900 credits a month, inside the 1,000 free monthly credits. A hundred products is 9,000 credits a month, inside a Starter pack of 10,000 credits; purchased credits last 12 months. See [pricing](https://perceptlayer.com/pricing) for what credits cost. The source guides cover each endpoint's parameters: [Idealo](https://perceptlayer.com/blog/idealo-api-price-comparison-json), [Walmart](https://perceptlayer.com/blog/walmart-product-search-api) and [Mercado Libre](https://perceptlayer.com/blog/mercado-libre-api-product-search).

To try the endpoints, [get a free API key](https://perceptlayer.com/signup) and run a search in the playground on the [Idealo API page](https://perceptlayer.com/apis/idealo), the [Walmart API page](https://perceptlayer.com/apis/walmart) or the [Mercado Libre API page](https://perceptlayer.com/apis/mercadolibre). Data is collected from publicly available sources and returned as found at the time of the call.

## APIs in this post

-   [Idealo **1 credit per call** · 13 endpoints](https://perceptlayer.com/apis/idealo)
-   [Mercado Libre **1 credit per call** · 11 endpoints](https://perceptlayer.com/apis/mercadolibre)
-   [Walmart **1 credit per call** · 2 endpoints](https://perceptlayer.com/apis/walmart)

## Frequently asked questions

### How do I convert prices in different currencies in Python?

Parse each price into a `Decimal`, attach its ISO currency code, and divide by a reference rate for the collection date, such as the ECB's euro rate. Round with `quantize(Decimal("0.01"))`. Keep the original amount, currency, rate and rate date on the row, so any conversion can be checked and recomputed later.

### Why use Decimal instead of float for prices?

Floats can't represent most decimal fractions exactly, so sums and conversions drift by small amounts. `Decimal(str(89.99))` is exactly 89.99. Build `Decimal`s from strings, not floats: `Decimal(89.99)` carries the float's binary error into the result. Round only once, when you store or display the converted value.

### Which exchange rate source should I use?

A published, dated reference rate you can cite. The ECB's euro reference rates cover 29 currencies on working days and are free; for currencies it doesn't publish, such as the Argentine peso, use that country's central bank, such as the BCRA's Communication A 3500 rate. Record which source and date you used for every row.

### How do I parse prices with commas as decimal separators?

Look at the string: if it has both a comma and a dot, the one that comes last is the decimal separator. With only a comma followed by two digits, as in `162,90 €`, the comma is decimal. Remove the other separator, swap a decimal comma for a dot, and build a `Decimal`.

### Can I compare converted prices across countries directly?

Not without caveats. German consumer prices include VAT by law, while US shelf prices usually exclude sales tax; shipping and the exact product also differ. Convert currency first, then adjust for tax and shipping where your analysis needs it, and compare matched products rather than similar ones.

currency conversion price data python data engineering ecb

## More from the blog

1.  [Data engineering](https://perceptlayer.com/blog/category/data-engineering) 12 Oct 2026 Perceptlayer 7 min read
    
    ## [Incremental API Data Pulls: Fetch Only New Records](https://perceptlayer.com/blog/incremental-api-data-pulls)
    
    Pull only new listings: sort newest first, keep a high-water mark or a seen-ID set per query, and stop paging early. Python tested on Craigslist and eBay data.
    
2.  [Data engineering](https://perceptlayer.com/blog/category/data-engineering) 29 Sept 2026 Perceptlayer 6 min read
    
    ## [Save API JSON to Parquet in Python](https://perceptlayer.com/blog/save-api-json-to-parquet-python)
    
    Write API JSON to date-partitioned Parquet with pyarrow and a fixed schema, so a price that arrives as 441 one day and 393.73 the next doesn't break it.
    
3.  [Data engineering](https://perceptlayer.com/blog/category/data-engineering) 6 Aug 2026 Perceptlayer 8 min read
    
    ## [Paginate a Web Data API in Python: Safe Page Loops](https://perceptlayer.com/blog/paginate-web-data-api-python)
    
    Page through the eBay search endpoint in Python with has\_more, dedupe by product\_id and a credit ceiling. Each page costs 1 credit.
