International financial data breaks standard Power Query type conversions in silent, dangerous ways. This lesson teaches you how to build locale-aware number parsers, implement currency-specific rounding rules, and normalize multi-currency transaction tables to a single base currency using a robust, production-ready M pipeline.

Picture this: your company has just acquired a European subsidiary, and their sales data lands in your inbox as a CSV export. The numbers look like this: 1.234.567,89 for what should be one million, two hundred thirty-four thousand, five hundred sixty-seven dollars and eighty-nine cents. Your Power Query query chokes. It either turns that into an error, reads it as 1.234 (one thousand, two hundred thirty-four), or — worst of all — silently produces a wrong number that flows into your financial reports without anyone noticing.
Meanwhile, the currency column contains a mix of EUR, GBP, USD, and JPY values, and finance needs everything normalized to USD using exchange rates pulled from a separate lookup table. Oh, and the rounding rules are different for each currency: JPY gets rounded to the nearest whole number, EUR to two decimal places, and Bitcoin (yes, they're experimenting) to eight decimal places.
This lesson teaches you how to build a production-ready pipeline in Power Query M that handles all of this correctly and systematically. By the end, you'll have reusable functions for locale-aware number parsing, configurable rounding strategies, and a multi-currency normalization engine that you can drop into any project.
What you'll learn:
Number.FromText with culture parameters to handle international number formatsYou should be comfortable with the basics of Power Query and M syntax before diving in. Specifically, you'll benefit from having read about M Language Fundamentals: Syntax, Types, and Expressions for Power Query and Writing Custom M Functions from Scratch in Power Query. A working knowledge of records and lists will also help — if those feel shaky, the article on List and Record Operations in M: Transform, Select, and Combine Data Structures is a good refresher.
Before you write a single line of M, you need to understand the root cause of number formatting failures. In most English-speaking countries, numbers follow this pattern:
.) — e.g., 1234.56,) — e.g., 1,234.56But in Germany, France, much of Latin America, and many other places, the roles are reversed:
,) — e.g., 1234,56.) — e.g., 1.234,56Switzerland uses an apostrophe as the thousands separator (1'234.56). Some South Asian countries use a two-digit grouping pattern after the first three digits (12,34,567.89).
When Power Query loads a CSV and you ask it to change a column type to number, it uses the locale of your machine by default. If your machine is set to en-US and the data was formatted for de-DE, the conversion will either fail or silently corrupt every number in the column. Silent corruption is the scariest outcome — your data looks plausible but is wrong.
Warning: Never rely on the default type-change behavior for numeric columns from international sources. Always specify the culture explicitly, or parse the text yourself. A column of
1.234,56values silently becoming1.234(because the comma was treated as a delimiter and everything after it was dropped) is an easy mistake to miss in a large dataset.
M gives us two primary functions for converting text to numbers with culture awareness:
The signature is:
Number.FromText(text as nullable text, optional culture as nullable text) as nullable number
The culture parameter accepts a BCP 47 language tag — things like "de-DE" for Germany, "fr-FR" for France, "en-US" for the United States. When you supply it, M uses that culture's decimal and grouping separator rules.
// Parsing a German-formatted number
Number.FromText("1.234.567,89", "de-DE")
// Returns: 1234567.89
// Parsing a US-formatted number
Number.FromText("1,234,567.89", "en-US")
// Returns: 1234567.89
Both return the same underlying number — 1234567.89 — just parsed according to their respective locale rules. The power here is that you get to decide which rule applies, regardless of what your machine's regional settings say.
In a real scenario, you often receive data where the locale is known at the source level but not stamped on each row. You might know "this file came from our German subsidiary" and apply one rule to all its rows. Here's a reusable function to encapsulate that:
// ParseLocalizedNumber: Convert locale-formatted text to a number
// culture examples: "de-DE", "fr-FR", "en-US", "ja-JP"
let
ParseLocalizedNumber = (rawText as text, culture as text) as nullable number =>
let
trimmed = Text.Trim(rawText),
// Strip any currency symbols or whitespace that crept in
cleaned = Text.Remove(trimmed, {"$", "€", "£", "¥", "₿", " ", "\u00a0"}),
result = try Number.FromText(cleaned, culture) otherwise null
in
result
in
ParseLocalizedNumber
Notice the try...otherwise null pattern. This is intentional — if a value genuinely can't be parsed (because it's a label like "N/A" or an empty string), returning null is almost always preferable to crashing the entire query. You can then handle nulls downstream with a default value or a flag column.
Tip: The
\u00a0character is a non-breaking space (Unicode U+00A0). Many European accounting systems use it as a thousands separator. It looks identical to a regular space in most editors, so if you're getting mysterious parse failures on numbers that look clean, this invisible character is often the culprit. Always include it in your cleanup strip.
The harder case is a table where different rows have different locales — for instance, a global transaction table where each row has a source_region column. Here you need to map a region to a culture string, then apply the right parser per row.
let
// A small lookup record mapping region codes to BCP 47 culture strings
CultureMap = [
DE = "de-DE",
FR = "fr-FR",
US = "en-US",
GB = "en-GB",
JP = "ja-JP",
CH = "de-CH"
],
ParseLocalizedNumber = (rawText as text, culture as text) as nullable number =>
let
cleaned = Text.Remove(Text.Trim(rawText), {"$", "€", "£", "¥", " ", "\u00a0"}),
result = try Number.FromText(cleaned, culture) otherwise null
in
result,
// Assume "Source" is your raw table with columns [region, raw_amount]
WithParsedAmounts = Table.AddColumn(
Source,
"amount",
each
let
culture = Record.FieldOrDefault(CultureMap, [region], "en-US")
in
ParseLocalizedNumber([raw_amount], culture),
type nullable number
)
in
WithParsedAmounts
Record.FieldOrDefault is doing important defensive work here: if a region code shows up that isn't in your CultureMap, it falls back to "en-US" instead of throwing an error. You'd want to log or flag those unknowns in production — but at least the pipeline won't collapse.
Once you have clean numbers, rounding becomes the next challenge. Most people use Number.Round and never think about it — until finance asks why your totals are off by a penny, or why the auditor's spreadsheet doesn't match.
M's Number.Round function accepts an optional third argument, RoundingMode.Type:
| Mode | M Constant | Behavior |
|---|---|---|
| Half-up | RoundingMode.AwayFromZero |
2.5 → 3, -2.5 → -3 (common in finance) |
| Half-to-even | RoundingMode.ToEven |
2.5 → 2, 3.5 → 4 (banker's rounding) |
| Default | (omit) | Half-up for positive, half-to-even behavior varies by version |
// Half-up (away from zero) — the one finance usually wants
Number.Round(2.5, 0, RoundingMode.AwayFromZero) // → 3
Number.Round(-2.5, 0, RoundingMode.AwayFromZero) // → -3
// Banker's rounding (half-to-even) — statistically unbiased
Number.Round(2.5, 0, RoundingMode.ToEven) // → 2
Number.Round(3.5, 0, RoundingMode.ToEven) // → 4
Key insight: Banker's rounding (half-to-even) exists to eliminate systematic upward bias when rounding large datasets. If you always round 0.5 up, the sum of rounded values will consistently exceed the sum of the originals. For aggregate financial reporting, this bias compounds. The IEEE 754 standard (which governs floating-point arithmetic in most programming languages) defaults to half-to-even for this reason. Ask your finance team which mode your organization's accounting rules require — don't assume.
Different currencies have different precision requirements baked into international standards (ISO 4217):
// CurrencyPrecision: A record mapping currency codes to decimal precision
let
CurrencyPrecision = [
USD = 2, EUR = 2, GBP = 2, CAD = 2, AUD = 2,
CHF = 2, SEK = 2, NOK = 2, DKK = 2,
JPY = 0, KRW = 0, VND = 0,
KWD = 3, BHD = 3, OMR = 3,
BTC = 8
],
RoundForCurrency = (
amount as nullable number,
currencyCode as text,
optional roundingMode as nullable number
) as nullable number =>
let
precision = Record.FieldOrDefault(CurrencyPrecision, currencyCode, 2),
mode = if roundingMode = null then RoundingMode.AwayFromZero else roundingMode,
rounded = if amount = null then null else Number.Round(amount, precision, mode)
in
rounded
in
RoundForCurrency
Now you have a single function that knows about currency precision rules. When you call RoundForCurrency(1234.5678, "JPY"), you get 1235. When you call RoundForCurrency(1234.5678, "KWD"), you get 1234.568. No hard-coded precision scattered throughout your transformations.
Now for the centerpiece: converting a table with mixed currencies into a single base currency (USD in our example), using a dynamic exchange rate table.
Your exchange rate source might be a static table you maintain, a connection to a finance API, or a sheet in SharePoint. Whatever the source, normalize it into this shape before you do anything else:
| from_currency | to_currency | rate | effective_date |
|---|---|---|---|
| EUR | USD | 1.0821 | 2024-01-15 |
| GBP | USD | 1.2734 | 2024-01-15 |
| JPY | USD | 0.00671 | 2024-01-15 |
| USD | USD | 1.0000 | 2024-01-15 |
Note: Always include a
USD → USDrow with a rate of1.0. This lets your conversion logic treat every currency uniformly — including amounts already in USD — without special-casing the base currency. It sounds obvious, but missing this is a common source of null values appearing in your output for USD-denominated rows.
let
// ExchangeRates is assumed to be a buffered table with columns:
// [from_currency, to_currency, rate]
// If you're doing date-aware lookups, add effective_date filtering upstream.
GetExchangeRate = (fromCurrency as text, toCurrency as text) as nullable number =>
let
matches = Table.SelectRows(
ExchangeRates,
each [from_currency] = fromCurrency and [to_currency] = toCurrency
),
rate = if Table.IsEmpty(matches)
then null
else matches{0}[rate]
in
rate
in
GetExchangeRate
Warning: Calling
Table.SelectRowsinside a function that runs once per row is a known performance trap. When this function is applied across thousands of rows, Power Query may re-evaluate the entireExchangeRatestable for each call, unless that table has been buffered into memory. Always wrap your rate lookup table inTable.Bufferbefore using it in a per-row function.
Now we bring all three pieces together — locale parsing, currency-aware rounding, and rate conversion — into a single coherent transformation:
let
// --- Configuration ---
BaseCurrency = "USD",
CurrencyPrecision = [
USD = 2, EUR = 2, GBP = 2, JPY = 0, KWD = 3, BTC = 8
],
CultureMap = [
DE = "de-DE", FR = "fr-FR", US = "en-US",
GB = "en-GB", JP = "ja-JP"
],
// --- Helper Functions ---
ParseLocalizedNumber = (rawText as text, culture as text) as nullable number =>
let
cleaned = Text.Remove(Text.Trim(rawText), {"$", "€", "£", "¥", " ", "\u00a0"}),
result = try Number.FromText(cleaned, culture) otherwise null
in
result,
RoundForCurrency = (amount as nullable number, currencyCode as text) as nullable number =>
let
precision = Record.FieldOrDefault(CurrencyPrecision, currencyCode, 2),
rounded = if amount = null then null
else Number.Round(amount, precision, RoundingMode.AwayFromZero)
in
rounded,
// --- Data Sources ---
// Buffer the rates table once — critical for performance
RatesBuffered = Table.Buffer(ExchangeRates),
GetRate = (fromCcy as text) as nullable number =>
let
matches = Table.SelectRows(
RatesBuffered,
each [from_currency] = fromCcy and [to_currency] = BaseCurrency
),
rate = if Table.IsEmpty(matches) then null else matches{0}[rate]
in
rate,
// --- Main Transformation ---
// Assume RawTransactions has columns: [region, currency, raw_amount]
Step1_ParseAmounts = Table.AddColumn(
RawTransactions,
"amount_local",
each
let
culture = Record.FieldOrDefault(CultureMap, [region], "en-US")
in
ParseLocalizedNumber([raw_amount], culture),
type nullable number
),
Step2_AddRate = Table.AddColumn(
Step1_ParseAmounts,
"exchange_rate",
each GetRate([currency]),
type nullable number
),
Step3_ConvertToBase = Table.AddColumn(
Step2_AddRate,
"amount_usd_raw",
each if [amount_local] = null or [exchange_rate] = null
then null
else [amount_local] * [exchange_rate],
type nullable number
),
Step4_RoundToBase = Table.AddColumn(
Step3_ConvertToBase,
"amount_usd",
each RoundForCurrency([amount_usd_raw], BaseCurrency),
type nullable number
),
// Clean up intermediate columns
Step5_Finalize = Table.RemoveColumns(
Step4_RoundToBase,
{"raw_amount", "amount_usd_raw"}
)
in
Step5_Finalize
This pipeline is explicit about each transformation step, which makes it easy to debug. If amount_usd looks wrong for a row, you can inspect amount_local, exchange_rate, and amount_usd_raw independently to find where the problem entered.
Notice that CurrencyPrecision, CultureMap, and BaseCurrency are all defined at the top of this query. In a real deployment, you'd want to move these into a shared parameter table so they're editable from one place and accessible by multiple queries. The pattern for doing this is covered in depth in Cross-Query State Management and Shared Parameter Tables in Power Query M: Centralizing Configuration for Multi-Report Deployments. Even at the foundation level, understanding why this matters is important: if your BaseCurrency is hardcoded in five different queries and you need to change it, you're doing five edits and risking inconsistency.
Set up the following scenario in Power Query using blank queries:
1. Create a mock transaction table. In Power BI Desktop or Excel, open Power Query Editor and create a new blank query. Paste this M code:
let
Source = Table.FromRecords({
[region = "DE", currency = "EUR", raw_amount = "1.250,75"],
[region = "GB", currency = "GBP", raw_amount = "£ 890.40"],
[region = "JP", currency = "JPY", raw_amount = "125 430"],
[region = "US", currency = "USD", raw_amount = "$2,100.00"],
[region = "DE", currency = "EUR", raw_amount = "N/A"]
})
in
Source
Name this query RawTransactions.
2. Create a mock exchange rate table. Create a second blank query:
let
Source = Table.FromRecords({
[from_currency = "EUR", to_currency = "USD", rate = 1.0821],
[from_currency = "GBP", to_currency = "USD", rate = 1.2734],
[from_currency = "JPY", to_currency = "USD", rate = 0.00671],
[from_currency = "USD", to_currency = "USD", rate = 1.0000]
})
in
Source
Name this query ExchangeRates.
3. Build the conversion pipeline. Create a third query using the full pipeline code from the previous section. Verify that:
"N/A" row in amount_local becomes null£ symbol is stripped from the GBP amountChallenge: Add a BTC row to RawTransactions with amount "0,00234500" (Swiss locale, because why not) and region "CH". Add a BTC-to-USD rate of 42000.00 to ExchangeRates. Verify the final USD amount rounds to 2 decimal places (since that's the base currency precision, not the source currency precision).
"My numbers are parsing but the values are completely wrong."
You have a locale mismatch. Add a diagnostic step after parsing: Table.AddColumn(Source, "check", each Number.ToText([amount_local])) and inspect a few rows manually. If a German value like "1.500,00" is parsing as 1.5 instead of 1500, you're using "en-US" culture when you need "de-DE".
"Some rows show null in the exchange rate column even though the currency exists in my rates table."
Check for trailing spaces or inconsistent casing in your currency codes. "EUR " (with a trailing space) will not match "EUR". Add Text.Trim(Text.Upper([currency])) to normalize your currency column before the lookup. Also verify that ExchangeRates actually has the to_currency column set to your base currency, not a different one.
"My pipeline is extremely slow with large transaction tables."
The per-row Table.SelectRows call on ExchangeRates is almost certainly the cause. Confirm that RatesBuffered = Table.Buffer(ExchangeRates) is present and that your GetRate function references RatesBuffered, not the original ExchangeRates query. See M Language Performance Patterns and Anti-Patterns: Optimize Power Query for Speed for a deeper treatment of this pattern.
"I'm getting a DataFormat.Error on the Number.FromText call."
The try...otherwise null wrapper should prevent this from crashing your query. If you're seeing it anyway, it means you removed the error handling at some point, or you're calling Number.FromText directly in a type-change operation rather than through your parser function. Always parse via the function, never via Table.TransformColumnTypes for international data.
"My rounding doesn't match the finance team's spreadsheet."
This is almost always a rounding mode mismatch. Excel defaults to half-up rounding. If your finance team is using Excel to verify, make sure you're using RoundingMode.AwayFromZero. Banker's rounding (which M sometimes applies by default) will produce different results for values ending exactly in .5 or .05 etc.
You've now built a complete pipeline that handles the three hardest problems in financial data processing:
Number.FromText with explicit culture parameters and defensive text cleanupRoundingMode constantsThe key architectural decisions — buffering the rate table, centralizing configuration, using try...otherwise null for resilience, and separating parsing from conversion — are what separate a query that works in development from one that survives production.
From here, consider these natural extensions:
GetRate function to filter by effective_date, matching each transaction to the rate that was active on its transaction date. The article on Working with Dates, Times, and Duration Values in Power Query M covers the date comparison patterns you'll need.null for unparseable values, add a parse_error boolean column that downstream reports can use to filter or alert on data quality issues. This connects to the broader topic in Implementing Custom Table.Schema Validation and Type Enforcement Pipelines in Power Query M for Robust Data Quality Contracts.ParseLocalizedNumber, RoundForCurrency, and GetRate into a shared function query so they can be called across your entire Power BI dataset — the pattern is explained in Building a Reusable Function Library in Power Query.The skills you've practiced here — defensive parsing, configurable transformation logic, and performance-conscious design — are the foundation of reliable data engineering in M, not just for currency work but for any domain where data arrives in inconsistent formats.