Skip to content

Localization, currency & time zones

Three questions look like one problem and are not. What words does this person read? is translation. How is a value written down for this person? is formatting. What does this number mean? is the unit — the currency code on an amount, the time zone an instant renders into, the calendar a date belongs to. Systems that fuse them produce the characteristic failures: a French user who wants US number formatting and cannot have it, a report that sums euros and dollars into a number with no name, a scheduled job that runs an hour early for three weeks every March.

The load-bearing decisions on this page:

  • Translation is a typed property of the component it translates, not a parallel artifact. A translatable string is not a string — it is a localizable<text>, and the set of translatable things is therefore derived from the metadata schema rather than maintained as a list. A feature cannot ship untranslatable, because the property’s type is what makes it translatable.
  • Every tenant is a multi-currency tenant. A currency value is an amount plus an ISO 4217 code, in two real columns, whether the org trades in one currency or nine. There is no enablement step, so there is nothing to make irreversible.
  • Stored values are absolute; locale lives only at the edges. Nothing about a viewer ever reaches a column. Formatting is a render-time function, and the API’s canonical representation is unformatted.

A user carries four localization settings. They are separate fields, they default independently, and none is derived from another.

Setting Type What it selects What it never touches
language BCP 47 language tag — fr, pt-BR Which translation of a label, help text, picklist label, or error message renders Number, date, or currency formatting
locale CLDR locale identifier — en-US, de-AT Number grouping and decimal separator, date and time patterns, first day of week, name order, address layout, currency symbol placement Which words are used
timeZone IANA zone name — America/Chicago The wall-clock rendering of an instant The instant itself
currency ISO 4217 code The display currency for converted totals and roll-ups the viewer reads The currency stored on any record

The separation is not pedantry. A French-speaking process engineer in Houston wants a French interface and American date and number formatting; a German company’s Polish subsidiary wants Polish labels, Polish formatting, and reporting in euros. Collapsing language into locale gets one of the two wrong for every such person, and there are more of them than a single-country design predicts.

Resolution is user → tenant → platform. Each setting falls back independently: a user with no timeZone inherits the tenant’s, not the tenant’s language’s. The platform floor is en, en-US, UTC, and the tenant’s corporate currency. The browser is consulted exactly once — as a prefill on the user-creation form — and never again, because a value that silently changes when someone opens the app from an airport lounge is not a setting.

A component property declared localizable<text> holds a default string plus zero or more translations keyed by language tag:

"label": {
"default": "Spare Parts",
"fr": "Accessoires expédiés séparément",
"de": "Lose verladenes Zubehör"
}

The properties typed this way are exactly the ones a person reads:

Where Properties
Object label, pluralLabel, description
Field label, helpText, description, placeholder
Value set every value’s label (the value itself is never translated — see below)
Validation rule errorMessage
Page layout section headings, tab labels, region titles
App label, description, tab labels
Message template subject, body per channel
List view label
Lifecycle / path stage labels, guidance text
Custom error the message body of any error raised from the pure or effectful tier
Field type validator failure messages

That table is documentation, not configuration. The authoritative list is the metadata schema: anything typed localizable<text> is translatable, and a new component type that adds a human-facing string gets translation by declaring the property’s type correctly. There is no separate registry of translatable types to forget to update.

An enum value’s stored value is never translated. A picklist stores a stable API value (shipped_loose); the label a person sees is a localizable<text> on the value-set row. This is the difference between a translation and a data corruption: translating the stored value would mean the same record holds Confirmed for one user and Bestätigt for another, and every formula, filter, and integration that compares against a literal would break in one language and not the other.

Resolution walks four steps and cannot fail:

  1. The viewer’s exact language tag — pt-BR.
  2. The base language — pt.
  3. The tenant’s default language.
  4. The property’s default string.

A missing translation renders the next-best string. It never renders a key, never renders empty, and never raises. A screen that half-renders because nobody translated a help bubble is a worse outcome than a screen with one English sentence on it, and the same reasoning governs notification templates, which resolve on the same chain.

Silence is the risk that trade creates, so completeness is measurable and can be made enforceable. locale_settings.strictLocales names languages for which translation completeness is a deploy gate: a component with an untranslated localizable<text> fails validate, naming the component, the property, and the language. It is opt-in per language because the alternative — gating every release on every language — is how organizations end up disabling the check entirely.

A currency field is a composed field type in the sense the field-type system already defines — a storage primitive plus validators plus a format — except that its storage is two columns rather than one:

Column Postgres type Holds
<field> numeric(p,s) The amount, exact decimal
<field>_currency char(3) ISO 4217 code, constrained to the tenant’s active currencies

Both columns are always present. In a tenant that trades only in US dollars, the code column carries a default and a single-value check constraint — it costs three bytes and it means there is no such thing as turning multi-currency on. Adding a second currency activates a row in a value set. That is the whole migration, and it runs in both directions.

The corporate currency is one tenant-level setting: the currency roll-ups, reports, and cross-currency comparisons resolve into when nothing more specific is declared. It is the reporting unit of account, not a default for record data.

Rates are not a bespoke object. They are a keyed, effective-dated configuration set — the mechanism that already exists for the freight rate an invoice prices against:

{
"key": "config.exchange_rates",
"label": "Exchange rates",
"type": "config_set",
"body": {
"keyed": true,
"key": ["iso"],
"effectiveDated": true,
"scopes": ["org"],
"fields": {
"iso": { "type": "text", "validators": ["regex(^[A-Z]{3}$)"] },
"rate": { "type": "number", "params": { "precision": 20, "scale": 10 } }
}
}
}

A rate is the number of units of the corporate currency one unit of the named currency buys, over a date range. Because the set is effective-dated, “the rate on 14 March” and “the rate today” are the same lookup with a different date, and rate history is not a second feature — it is the storage model. Because it is configuration, a rate change deploys, diffs, and lands in the metadata audit as a before → after, which is the correct treatment for a number that moves every reported figure in the company.

Four, because three is one short.

Type Postgres Means Rendered
date date A calendar date with no time and no zone — an invoice date, a birth date Formatted per the viewer’s locale. Never shifted by timeZone.
time time A time of day with no date and no zone — a shift start Formatted per locale
datetime timestamptz An instant — a point on the universal timeline Converted to the viewer’s timeZone, then formatted per locale
zoned_datetime timestamp + text A future wall-clock time in a named zone — a meeting at 09:00 in America/Chicago on 14 March next year Rendered in its own zone by default, in the viewer’s zone on request, with both shown when they differ

The distinction between the last two is the one that costs money when it is missing. A datetime records something that happened: the instant is the truth, and the local hour is a rendering. A zoned_datetime records something that will happen at a local hour: the local hour is the truth, and the instant is derived. Storing a future appointment as an instant fossilizes today’s offset, so when a government moves a DST boundary — which happens somewhere most years — every affected appointment silently moves by an hour. Storing the local time and the zone, and deriving the instant against the current tz database, means the appointment stays at nine in the morning.

timestamptz does not store a time zone. It stores an instant, normalized to UTC; the name is historical. That is exactly the right storage for datetime and exactly the wrong storage for zoned_datetime, which is why the two are different types rather than a flag on one.

{
"key": "locale_settings",
"label": "Locale settings",
"type": "locale_settings",
"body": {
"defaultLanguage": "en",
"defaultLocale": "en-US",
"defaultTimeZone": "America/Chicago",
"activeLanguages": ["en", "fr", "de", "pt-BR", "ar"],
"strictLocales": ["fr"],
"pseudoLocale": false
}
}

activeLanguages is the set a user may select and the set strictLocales may draw from. Removing a language from it is a safe-delete: the check reports every user still holding it and every component carrying a translation that would be orphaned, and the deploy fails until the references are resolved. Translations are not silently discarded.

{
"key": "currency_settings",
"label": "Currency settings",
"type": "currency_settings",
"body": {
"corporateCurrency": "USD",
"activeCurrencies": ["USD", "CAD", "EUR", "JPY"],
"rateSet": "config.exchange_rates",
"rounding": "half_away_from_zero"
}
}

Deactivating a currency is also a safe-delete: it fails if any record holds it, naming the objects and the row counts. A currency in use cannot be removed from the vocabulary that gives its stored amounts meaning.

{
"key": "invoice.total_sell",
"label": { "default": "Total Sell", "fr": "Prix de vente total" },
"type": "field",
"body": {
"fieldType": "currency",
"params": { "precision": 18, "scale": 4 },
"rateDate": "record.invoice_date",
"required": true
}
}

rateDate has no default and its absence fails deploy. It names the date that selects the exchange rate whenever this field is converted — a date column on the record, or the literal "current" for a value that should always convert at today’s rate. There is no third behavior and no implicit one, for the same reason a schedule must name its time zone: a platform that guesses which rate to use produces numbers that are plausible, wrong, and unfalsifiable by the person reading them. Refusing to guess costs one line in a component and removes an entire class of quiet financial error.

A translated component, and the locale slice

Section titled “A translated component, and the locale slice”

Translations live on the component. On disk they may be projected into per-language sidecar files so a translation vendor round-trips only strings:

objects/invoice.json # default strings, structure, everything else
objects/invoice.fr.i18n.json # { "label": "...", "fields.total_sell.label": "..." }
objects/invoice.de.i18n.json

The sidecars are a file layout, not a component boundary. Retrieve merges them into the component; diff runs on the merged form; apply writes the merged form. A sidecar cannot introduce a key the base component does not have, cannot omit one that strictLocales requires, and cannot exist for a component that does not. Deleting a field deletes its translations in the same transaction, because they were never separate things.

That distinction is the design decision this section exists to record. Modeling a translation as its own component makes four failures possible: a translation that deploys without its subject, a translation that survives its subject’s deletion, a label rename whose translations silently keep the old meaning, and a diff that cannot show a reviewer the two halves of one change. Modeling it as a property of the subject makes all four unrepresentable. The vendor workflow — translators want files of strings, not files of structure — is a projection problem, and it is solved at the file layer where it belongs.

Verified 2026-09-09 — there is no i18n noun in the CLI today, so nothing in this section has a command-line form yet. The three commands below are the intended shape, not commands that can be run: the shipped binary answers command i18n:export not found and exits 2 for each of them. The capability is tracked as CAOS-283 under epic CAOS-93, and neither of those specifies a CLI surface — the shape below is this page proposing one.

# Intended shape. The CLI has no i18n noun today - see the note above (CAOS-283).
caos i18n export --locale fr --scope objects/invoice --format xliff2
caos i18n import ./invoice.fr.xlf
caos i18n status --locale fr

Export emits XLIFF 2.1 — a bilingual file with source, target, and a note carrying the string’s location and its help text as translator context. XLIFF because translation agencies and CAT tools already read it; a proprietary column format makes every vendor build a parser before they can price the job. status reports completeness per language: total translatable strings, translated, untranslated, and stale — where “stale” means the default string changed after the translation was recorded, which is the failure a completeness count alone hides.

Import is permission-gated by metadata.translate, a system permission that authorizes deploying locale slices only. A translator can change every French string in the org and cannot change a formula, a permission set, or an English label. The three access planes already distinguish capability from content; translation is a capability, and giving it out should not require handing over the deploy pipeline.

Formatting is a pure value → string function of the stored value and the viewer’s locale, evaluated at render — in the package-delivered surface that presents the value, never in the engine, which stores and returns the canonical value unformatted. It runs in exactly one place and it never runs anywhere else:

  • Never on write. No input parser stores a formatted string. A number typed as 1.234,56 in a German locale is parsed to the exact decimal 1234.56 and that is what the column holds.
  • Never in storage. The column holds the canonical value, as field types already require. Reporting, export, formulas, and roll-ups all read the same value the API returns.
  • Never in the pure tier. A formula field or calc function cannot call a locale-dependent formatter. Its output would depend on who last looked at the record, which is precisely the property that makes a value uncacheable and a computation non-deterministic — the same rule that stops a stored computed field from reading locale-scoped configuration.

The canonical representation is unformatted and locale-free:

{
"id": "8f3a…",
"invoice_date": "2026-03-14", // date — no time, no zone
"submitted_at": "2026-03-14T21:07:33.412Z", // datetime — an instant, UTC
"install_at": { // zoned_datetime
"local": "2026-11-02T09:00:00",
"zone": "America/Chicago"
},
"total_sell": { "amount": "184620.5000", "currency": "USD" }
}

Two properties of that payload are deliberate. Numbers are strings, because JSON’s number type is IEEE binary floating point and the platform’s numbers are exact decimal; serializing 184620.50 as a JSON number is a lossy cast performed silently by the parser on the other end. And a money value is an object, not a bare number, because an amount without its code is not a quantity — it is a quantity-shaped hazard that some downstream system will add to a different currency.

A client that must not reimplement CLDR can ask the server to render as well as return. Accept-Language, X-CAOS-Locale, and X-CAOS-Timezone on a request with ?display=true add a parallel display block:

{
"total_sell": { "amount": "184620.5000", "currency": "USD" },
"display": {
"invoice_date": "14/03/2026",
"submitted_at": "14 mars 2026 à 16:07",
"total_sell": "184 620,50 $ US"
}
}

The rendered strings are additive and never a substitute. There is no mode in which the API returns "14/03/2026" in the invoice_date field, because the first integration that parses it with the wrong locale will do so silently and be wrong for eleven days out of every month.

Locale-aware collation is a query property, not a storage property. Text columns are stored and indexed under a deterministic collation so indexes are stable and unique constraints mean what they say; a list view or report that needs alphabetical order in the viewer’s language applies an ICU collation at query time. Where a locale is known to be hot — the tenant’s default, and any language in strictLocales — the platform maintains a collated index for it, governed by the same index-lifecycle rules as any other index. A locale with no collated index sorts correctly and more slowly, and the query planner’s cost shows up in the execution trace rather than as a mystery.

Instants are stored in UTC and rendered per viewer. Two people in Houston and Frankfurt open the same record and see the same instant written two ways. Changing a timeZone setting changes rendering and nothing else — a fact worth stating on the surface where users change it, because “I changed my time zone and the data moved” is otherwise a support ticket every single time.

A date is never converted. An invoice dated 14 March is dated 14 March in Frankfurt. The failure this rule prevents is the classic one: a date stored as an instant at local midnight, read in a zone one hour west, and rendered as the previous day. Because date is a real Postgres date with no time component, there is no offset available to apply and the bug is not expressible.

Bucketing by day is a zone-dependent operation, so the zone is an explicit parameter. “Invoices per day” has a different answer in America/Chicago than in UTC, and both answers are correct. A report or dashboard that groups a datetime by day, week, month, or quarter declares its bucketZone — defaulting to the running user’s zone — and the result carries that zone in its header and its export. A number that changes when a different person runs the report, with nothing on the page saying why, is worse than either answer alone.

Scheduled work is not interpreted in a viewer’s zone. A schedule names its own IANA zone and the requirement is compile-time; the platform materializes occurrences into absolute instants ahead of time and resolves DST by stated policy — a spring-forward occurrence fires at the first instant after the gap, a fall-back occurrence fires once. In a tenant with users in five zones, “the nightly report” runs at one instant, and each of the five sees the run stamped in their own zone. It does not run five times, and it does not run at whichever zone the last admin to edit it happened to be sitting in.

Zone rules change, so derived instants are re-derived. The tz database is a versioned dependency; zoned_datetime derives its instant on read and on materialization rather than freezing one at write, and the scheduler’s short materialization horizon means already-shipped rules do not fossilize. A tz database update is a platform deploy with a stated blast radius: future zoned datetimes in affected zones shift, past instants do not move, and the affected rows are enumerable before the update lands.

Conversion is a function evaluated in the query, not a second column maintained on the record. convert(value, targetIso, onDate) takes a currency value, looks up the effective rate for (iso, onDate) in the rate set, multiplies in exact decimal, and returns a currency value in the target code.

Three consequences follow, and they are the reason for the design:

  • Roll-ups over mixed currencies work, and say what they did. A roll-up of a currency field converts each child at the rate effective on that child’s rateDate, sums in the parent’s currency, and returns a currency value carrying that code. The rate basis is recorded on the roll-up’s definition, so a reader can see it.
  • Cross-object formulas use dated rates like everything else. There is no static-rate fallback anywhere in the platform, because there is no precomputed converted column that a formula could be reading instead.
  • A missing rate is an error, not a substitution. If no rate row covers (EUR, 2026-03-14), the roll-up or report fails with a canonical error naming the ISO code and the date, class configuration, non-retriable. It does not fall back to the current rate, and it does not drop the row. A financial total that is quietly wrong is strictly worse than one that refuses to render, because only the second one gets fixed.

A roll-up may also declare sameCurrencyOnly: true, which rejects mixed-currency children at save rather than converting them. That is the right setting for a total that is contractually denominated in one currency, where a converted figure would be misleading even when it is arithmetically correct.

Sum-of-converted, never converted-sum. A report column totals the converted row values. Those two operations differ whenever rows convert at different rates, and the difference is not rounding noise — it is the whole point of dated rates. The platform computes the first and never the second.

Conversion produces more fractional digits than any currency has, so rounding is specified rather than emergent:

  • Intermediate arithmetic carries the full scale of the rate (10 fractional digits by default) and the amount. Nothing rounds mid-expression.
  • Rounding happens once, at the moment a value is materialized into a column or rendered, using round half away from zero — the native behavior of Postgres numeric and the rule the numeric model already fixes for every other computation.
  • Storage scale is the field’s declared numeric(p,s). Display scale is the ISO 4217 minor-unit count of the value’s currency — two for USD and EUR, zero for JPY, three for BHD — so a yen amount does not render with two meaningless decimal places.
  • A currency field whose declared scale is smaller than the largest minor-unit count among the tenant’s active currencies fails deploy validation, naming the currency. A field with scale 2 cannot faithfully store a Bahraini dinar, and finding that out at deploy is cheaper than finding it out in a reconciliation.

Direction is derived, never stored. The resolved language’s script determines dir; a page authored once lays out mirrored in Arabic or Hebrew with no second layout and no per-component flag.

That is only true because the layout vocabulary makes it true. Three rules in the page and component model carry it:

  • There is no left or right in the layout vocabulary — only start and end. Alignment, padding, ordering within a region, and iconography position are all direction-relative. A property that cannot express “left” cannot break in a right-to-left script.
  • Widths are fractions of a region, never pixels. German, Finnish, and Russian labels routinely run considerably longer than their English source, and the shortest strings expand the most in relative terms. A component sized to fit an English string is a component that clips a German one. Labels wrap; they do not truncate, because a truncated label is unreadable while a wrapped one is merely taller.
  • Interpolated values are bidi-isolated. A merge field inside a translated string is wrapped in Unicode isolate characters (U+2068 / U+2069), so a Latin-script invoice number embedded in an Arabic sentence renders in the right place instead of migrating to the wrong end of the clause.

A pseudo-locale makes all of this testable before a translator exists. With pseudoLocale: true, a qps-ploc language renders every translatable string bracketed and lengthened by roughly 40% with accented characters — [Ŝhíp Ĺöösé Ãççéssöríés……]. Untranslated strings are visible because they are unbracketed, and layouts that will break under translation break immediately, in development, where fixing them is cheap. It is a tenant setting rather than a build flag so that a specific environment can enable it; deploy validation rejects pseudoLocale: true in a production-class environment, because a user who finds it reports it as data corruption.

Content the customer authors — rich text in a record, an uploaded document — carries its own direction and is the customer’s responsibility. The platform renders it in an isolated context so that a right-to-left paragraph inside a left-to-right page does not reorder the page around it.

Record data is not translated by the platform

Section titled “Record data is not translated by the platform”

The Translation Workbench’s “data translation” idea — translating the values of certain records — is deliberately not reproduced as a platform-managed feature, for the reason given above: a viewer-dependent value in an ordinary column breaks every filter, comparison, and integration that reads it.

The need is real, so it is met with a type instead of a mode. A field may be declared localizable<text> at the object level, in which case its column is jsonb holding the whole locale map, and the map is the canonical stored value:

  • Read renders the viewer’s language through the same fallback chain as metadata.
  • Export and reporting see the map, or an explicitly named language slice — never an implicit one.
  • A formula, roll-up, or validation rule that reads the field must name a language (record.marketing_name.en); reading it unqualified is a compile error, exactly as a stored computed field may not read locale-scoped configuration. A column whose value depends on who is looking at it cannot feed a stored computation.

The cost is stated plainly in Limits: a jsonb map indexes and queries less well than a text column, so the type is for the handful of fields that genuinely need it — a product name shown to customers in four languages — and not a default.

Concern CAOS Reason
Active languages per tenant No fixed cap Each language multiplies translatable-string rows and completeness-check cost, both linear and both small. The practical governor is strictLocales, which gates deploys.
Translatable string length The field’s length validator; no separate cap A localizable<text> is text per language. Salesforce’s 1,000-character custom-label cap is a string-budget artifact, not a semantic boundary.
Locale-collated indexes One per language in strictLocales plus the tenant default; further ones are ordinary governed index components Every additional collated index is write amplification on the same column.
Active currencies No fixed cap Each is a value-set row plus a rate series. Cost is per rate lookup, and lookups are index probes on (iso, start_date).
Rate history Unbounded, effective-dated History is the storage model, not a retained log. Old rows are never pruned because a re-run of a historical report must reproduce the historical number.
Currency field scale Must be ≥ the largest ISO 4217 minor-unit count among active currencies Enforced at deploy, because a scale-2 column cannot hold a three-decimal currency and the failure is otherwise silent.
rateDate Required on every currency field No default is defensible. See Authoring.
Time zone on a schedule Required Inherited from background work; the same reasoning.
bucketZone on a time-grouped report Defaults to the running user’s zone; always stamped on the output A defaulted-but-invisible zone produces two correct answers that look like one wrong one.
Pseudo-locale Rejected by deploy validation in production-class environments It is a development instrument; in production it reads as corruption.
Translation deploy rights metadata.translate authorizes locale slices only Translation is a capability, and it should not require the deploy pipeline.

Translation is a parallel artifact with a curated surface. The Translation Workbench is enabled per org, languages are activated individually, and translators are assigned per language; translations are then retrieved and deployed as their own metadata types. Translations is one file per language (de.translation, or Acme__de.translation when packaged) covering custom labels, custom applications, quick actions, report types and their sections and columns, flow screens, choices and stages, custom tabs, web links, bots and dialogs, and several other component families (Translations). Object-scoped translations live in a different type, CustomObjectTranslation, one file per object per language (myCustomObject__c-de.objectTranslation), carrying fields, layouts, recordTypes, validationRules, webLinks, quickActions, sharingReasons, and fieldSets, plus the linguistic properties gender, startsWith, and caseValues for languages with grammatical case (CustomObjectTranslation).

Two structural consequences follow. First, retrieve() returns translations only for metadata types explicitly listed in package.xml — a retrieval that omits a type omits its translations, silently, and the omission looks like an untranslated org rather than an incomplete retrieval. Second, the translatable surface is a list, so it has gaps by construction: a component family is translatable when it has been added to these types, and until then it is not.

Custom labels are the mechanism for translatable free strings in code and Visualforce. A CustomLabel carries value (maximum 1,000 characters), a required language, shortDescription, protected, and categories; the master values live in one CustomLabels file and the translations live in the separate per-language Translations files (CustomLabels).

Language and locale are correctly separated, and this is worth copying rather than improving. On the user record, Language “controls the language for all text and online help” and in most editions overrides the org default, while Locale “determines the format of date, date/time, and number fields, and the calendar” and also affects name display order; Time Zone is a third, independent field (User Fields). Language support is tiered — fully supported languages, end-user languages, and platform-only languages for custom components Salesforce ships no translations for (Translations).

Temporal semantics are sound at the API layer. date fields “contain no time value — the time portion of a date field is not relevant and is always set to midnight in the Coordinated Universal Time (UTC) time zone”; dateTime values “are full timestamps with a precision of one second” and “are always transferred in the Coordinated Universal Time (UTC) time zone”; time carries millisecond precision (Primitive Data Types). What is missing is a type for a future wall-clock time in a named zone, which is why scheduled work has to reintroduce the zone by other means, and why a scheduled job anchored to a fixed offset can land on a local time that does not exist on a spring-forward date and silently not run — the failure mode and its sourcing are covered in background work.

Multi-currency is a mode, and turning it on is a one-way door. Enabling it is a Setup checkbox on Company Information, and Salesforce Help states plainly that “enabling multiple currencies introduces permanent changes in your org” (Enable Multiple Currencies). Turning it back off is conditional on the org containing no code that touches the feature: “if currency fields are referenced in Apex … you can’t disable multiple currencies for your organization” (Manage Multiple Currencies). For any org that has built on the capability, that condition is not satisfiable. An admin picks a corporate currency — “the currency of the corporate headquarters” — activates the currencies the company does business in, and sets each one’s conversion rate relative to the corporate currency.

Dated exchange rates are a second feature on top of the first, and they carry documented holes. DatedConversionRate (IsoCode, ConversionRate, StartDate, NextStartDate) requires Advanced Currency Management (DatedConversionRate). Once enabled, dated rates apply to “opportunities, opportunity products, opportunity product schedules, campaign opportunity fields, opportunity splits, and reports related to these objects and fields” — and to nothing else. They do not apply to forecasting, to currency fields on other objects, to other report types, or when “calculating formula fields with a formula return type of ‘Currency’”. Roll-up summary fields between opportunities and accounts cannot be created, opportunity currency fields cannot be filtered at the account level, and existing currency roll-up summaries linking opportunities to incompatible objects become disabled. Cross-object formulas “always use the static conversion rate,” bypassing dated rates entirely (About Advanced Currency Management).

That last cluster is the shape of the whole problem: conversion is materialized rather than evaluated, so every place a converted number could be derived — a roll-up, a cross-object formula, a report on the wrong object — either loses the dated rate or loses the feature.

Where CAOS is genuinely better:

  • Translatability is a property type, not a registry. localizable<text> makes the translatable surface a consequence of the metadata schema, so a new component family cannot ship with untranslatable strings and no per-type retrieval list can omit them.
  • Translations are a locale slice of the component they translate. One diff shows a label change and its translations together; safe-delete removes both; a translation cannot deploy without its subject or survive it. Vendor-friendly per-language files remain, as a file projection rather than a component boundary.
  • Every tenant has the currency column. Multi-currency is not a mode, so there is no irreversible enablement, no support ticket to undo it, and no class of Apex reference that can wedge an org into a decision it made once.
  • Conversion is evaluated, so dated rates apply everywhere. Roll-ups across currencies, cross-object formulas, and reports on any object all convert at the rate effective on a declared date. The entire family of Advanced Currency Management restrictions — disabled roll-ups, static-rate cross-object formulas, dated rates confined to the opportunity family — does not arise, because there is no precomputed converted column whose staleness those restrictions exist to manage.
  • A missing rate fails loudly. No silent substitution of a current rate for a historical one.
  • zoned_datetime is a first-class type. Future local times survive a tz-database change instead of drifting by an hour.
  • Day-bucketing declares its zone. A time-grouped report states the zone it counted in, on screen and in its export.
  • Numbers cross the API as exact decimal strings and money as amount-plus-code. Neither can be silently degraded by a JSON parser or added to a different currency.
  • A pseudo-locale ships in the platform. Layout breakage under translation is found in development, without a translator.
  • Translators do not need the deploy pipeline. metadata.translate authorizes locale slices and nothing else.

Parity: separate Language, Locale, and Time Zone settings on the user, with the user’s value overriding the org’s; per-language activation with named translators; instants stored and transferred in UTC; date as a zoneless calendar date; ISO 4217 codes on money; a single corporate currency as the reporting unit of account; conversion rates as dated configuration; and a file-based export/import round trip for translation vendors. Salesforce got the shape of localization right, and most of this page is a port rather than a redesign.

Costs and risks:

  • Conversion moves to read time. Salesforce materializes converted amounts precisely because evaluating them is not free. Every currency read that crosses codes is a rate lookup, and every roll-up over mixed currencies is a lookup per child. The mitigations — rate sets cached per metadata generation, (iso, start_date) indexed, roll-ups converting in a single set operation rather than row by row — must be benchmarked at real row counts, not assumed.
  • A three-byte column on every money field in every single-currency tenant. Paid up front, forever, to avoid an irreversible switch later. It is the right trade and it is still a cost.
  • rateDate is a modeling burden pushed onto the field author. Requiring it means it cannot be forgotten, but it can be declared wrong, and a wrong rate date produces numbers that look entirely reasonable. Deploy validation can check that the named column is a date on the same object; it cannot check that it is the right date.
  • Locale-aware sorting is a genuine index problem. A collated index per hot language is write amplification on the same column, and a language without one sorts more slowly. There is no configuration that makes both cheap.
  • localizable<text> record fields are a jsonb column. They index and query worse than text, they complicate uniqueness, and every pure-tier read of one must name a language. The type is deliberately narrow, and a tenant that reaches for it broadly will feel all three costs.
  • Completeness gating can block a release over a string. strictLocales is opt-in because a mandatory version of it is a rule teams route around.
  • The tz database is an external dependency on the critical path. Zone rule changes are political events on someone else’s schedule; deriving instants rather than freezing them is correct and it means a platform update can move a future appointment. The blast radius is enumerable, which is the most that can be said for it.
  • A pseudo-locale is a foot-gun with a guard rail. Deploy validation keeps it out of production-class environments; nothing keeps it out of a demo.
Component type Body
Locale settings locale_settings defaultLanguage, defaultLocale, defaultTimeZone, activeLanguages[], strictLocales[], pseudoLocale
Currency settings currency_settings corporateCurrency, activeCurrencies[], rateSet, rounding
Exchange rates config_set / config_entry Keyed by iso, effective-dated, rate as an exact decimal
Translations not a component A localizable<text> property on the component being translated; projected on disk as <component>.<lang>.i18n.json

A user’s language, locale, timeZone, and currency are record data on the user object, never component content — the same split permission sets draw between a set and its assignments, and for the same reason: promoting a release must not overwrite what people chose about their own screens.

Deploy classification. A translation-only change is metadata-only and takes effect at the next generation flip. Adding a language is metadata-only. Adding a currency is metadata-only, because the column already exists — this is the point of the design. Changing a currency field’s precision or scale is storage-affecting and runs as an online migration under expand → migrate → contract, like any other numeric scale change. A rate entry is a config_entry deploy, diffed and audited as a before → after.

Validation gates, run by validate before anything applies:

  • Every localizable<text> property has a non-empty default.
  • Every language in strictLocales has a translation for every localizable<text> property in scope.
  • Every language tag in activeLanguages is well-formed BCP 47; every locale is a known CLDR identifier; every time zone is a valid IANA name — never a fixed offset.
  • Every currency code is ISO 4217 and appears in activeCurrencies.
  • Every currency field declares a rateDate naming a date column on its own object, or "current".
  • Every currency field’s scale is ≥ the largest active minor-unit count.
  • pseudoLocale is false in production-class environments.

Salesforce Metadata API analogs, for migration mapping: Translations (per language) and CustomObjectTranslation (per object per language) both fold into localizable<text> properties on the components themselves; CustomLabel becomes an ordinary component with a localizable<text> value; CurrencyType and DatedConversionRate become currency_settings plus an effective-dated config_set; and User.LanguageLocaleKey, LocaleSidKey, and TimeZoneSidKey become the language, locale, and timeZone fields on the user object. A migration reads CustomObjectTranslation files per language and merges them into one component per object, which is a mechanical transform because the per-language files are keyed by the same component paths.