# Culture formatting — ToString(format, culture)

> Format numbers, currency, percentages and dates for a declared culture — `total.ToString("C", "sv-SE")` → `1 234,56 kr`. A per-call culture overrides; `app.DefaultCulture` (or the viewer's own) is `CultureInfo.CurrentCulture`, which every culture-less ToString, Parse, ToUpper, Compare and OrderBy follows, as in C#.

<!-- id: stdlib-culture-formatting · area: stdlib · stability: stable · html: https://osysharp.com/reference/stdlib/culture-formatting/ -->

## Summary        {#summary}
Format a number or date for a **declared culture**, spelled exactly as C#'s `IFormattable.ToString(format, provider)` —
the provider is a BCP-47 culture token (`"sv-SE"`, `"de-DE"`), not an ambient setting:

```osy syntax
var price = total.ToString("C", "sv-SE");     // "1 234,56 kr"  — Swedish krona, space grouping, comma decimal
var pct   = rate.ToString("P1", "en-US");     // "12.5 %"
var when  = order.CreatedAt.ToString("D", "de-DE");   // "Freitag, 5. Januar 2024"
```

The culture must be **declared** (a `cultures { }` set; the platform ships a default pack, so `sv-SE`/`de-DE`/… work
with no ceremony). A per-call culture is the override; `app.DefaultCulture` sets the app-wide default so a culture-less
`total.ToString("C")` still formats for it.

## Signature      {#signature}
```osy syntax
string  value.ToString(string format)                  // the app-default culture (else invariant)
string  value.ToString(string format, string culture)  // an explicit culture — the per-call override
string  value.ToString(string format, CultureInfo.InvariantCulture)   // NO culture, whatever the app's default
string  value.ToString(CultureInfo.InvariantCulture)
// declared once, app-wide:
app.DefaultCulture = "sv-SE";
// per-viewer (on the principal entity) + the read:
[PreferredCulture] Culture Locale;
string cur = Session.CurrentCulture;                   // the current viewer's effective culture token
```

## Description    {#description}
**The current culture is the app's, never the machine's.** `CultureInfo.CurrentCulture` is the viewer's
`[PreferredCulture]`, else `app.DefaultCulture`, else the invariant culture — never the culture of the host that happens
to run the code. So a value formats for the culture you name, or for that one, and a price renders the same on the
server and in every browser.

**Declared, closed set.** A culture token on a format call must belong to the app's declared `cultures { }` set. The
platform ships a default pack (`en-US`, `en-GB`, `sv-SE`, `de-DE`, `fr-FR`, `es-ES`, `it-IT`, `nl-NL`, `pt-BR`,
`ja-JP`, `zh-CN`, `ar-AE`); an app can vendor the block to curate. `ar-AE` is Arabic with the Gregorian calendar and
Latin digits (`2 أكتوبر 2026`, `1,234.50`) — what an Arabic page formats in when the app's default speaks another
language. A token outside the set is a **compile error** (with a
"did you mean").

**Supported formats (run in the browser, no round trip).** Over a `Decimal`, `Int32`, `Double` (fixed specifiers only),
`DateTime`, `DateOnly` or `TimeOnly`:

| Kind | Formats | Example (sv-SE) |
|---|---|---|
| Number | `N` `F` `D` (+ digits: `N2`, `F0`) | `1 234,56` |
| Currency | `C` (+ digits) | `1 234,56 kr` |
| Percent | `P` (+ digits) | `12,5 %` |
| Date/time | `d D t T g G M Y` | `2024-01-05` · `fredag 5 januari 2024` |
| Custom numeric | `#,##0.00`, `0.00` | `1 234,56` |
| Custom date | `yyyy-MM-dd`, `dd/MM/yyyy`, `MMMM d` | `2024-01-05` |

The client reproduces .NET exactly — the culture's separators (including a non-breaking-space group separator and a
`−` minus), currency symbol and placement, month/day names (with the **genitive** forms .NET selects when a day number
is adjacent, e.g. German `5. Juni` vs standalone `Jun`), and AM/PM.

A **`Double`** formats client-side too for the fixed specifiers (`N`/`F`/`C`/`P`) — the client rounds the value's
*exact* decimal expansion (a double is a terminating decimal), reproducing .NET including its type-specific rounding
(a double rounds half-to-even, `(2.5).ToString("F0")` → `"2"`, where a `Decimal` rounds half-away-from-zero). Prefer
`Decimal` for money regardless — it is exact end to end.

**Everything else runs on the server** (correct, one round trip): **parsing** a string back to a value; the numeric
`E`/`G`/`R`/`X` specifiers, a *custom pattern over a double*, and a double's plain `.ToString()` — these need
shortest-round-trip / 15-digit rounding that isn't reproduced client-side; non-Gregorian date calendars/eras under a
culture; and any format built at runtime.

**App default.** `app.DefaultCulture = "sv-SE";` makes a culture-**less** `value.ToString("C")` format for `sv-SE`
everywhere — server and client — without repeating the culture at each call. A per-call culture still overrides it. With
no default declared, a culture-less format is culture-neutral (invariant), unchanged.

**Per-viewer culture.** Give the principal entity a `Culture` property marked `[PreferredCulture]`, and a culture-**less**
`value.ToString("C")` renders in *the current signed-in user's* culture. `Culture` is a value-kind that stores a BCP-47
token (`"de-DE"`) — a picker or a plain string sets it — validated to be a real culture when written. The resolution
order is: **a per-call culture > the viewer's `[PreferredCulture]` > `app.DefaultCulture` > invariant**. It resolves at
runtime and, like every other supported format, runs **in the browser** (the viewer's culture is served with the app's
metadata, so there is no round trip); a viewer whose preferred culture is not one the app declared falls back to the
app default.

**An app in more than one language formats in the language being read.** With a locale file of its own
([A language's words — locales/<language>.osy](https://osysharp.com/reference/i18n/translations/)), a culture-less format renders in the culture of the reader's language — the viewer's own
culture when it speaks that language, else `app.DefaultCulture` when that does, else the app's declared culture for
it — so a Swedish reader sees `1 234,50 kr` where an English one sees `1,234.50 kr`. A mail formats in ITS language
([Which language a reader gets — and switching it](https://osysharp.com/reference/i18n/language/)). A package's code compiled into the app formats in the app's default culture, like the app's own.

**A number typed into a box follows the same order.** `NumberField` and `DecimalField` — and any `Field` bound to a
numeric member — render and accept their digits in the viewer's effective culture, not the browser's. So under
`app.DefaultCulture = "sv-SE"` the box shows `12,5` and takes `12,5`, `1 234,50` and `-12,5`, and the member behind it
still holds an exact `decimal`. A number written with another culture's decimal mark is not a number: `12.5` there
answers what an empty box answers, rather than being read as `125` or as `12.5` — either of which would be a wrong
number nobody could see.

`min`/`max` on the numeric controls mark the box invalid the moment the value falls outside, so the reader sees it as
they type rather than when the save is refused.

**The invariant culture.** `x.ToString("0.##", CultureInfo.InvariantCulture)` is C#'s spelling of "format this
the same everywhere" — a dot for the decimal mark and no grouping, whatever the app's default or the viewer's
preference. It is the one culture that needs no `cultures { }` entry, because it is not a place. Reach for it where the
text is read by a MACHINE rather than a person: a number written into a file format, a URL or a CSS value. Under
`app.DefaultCulture = "sv-SE"`, `(1234.5m).ToString("0.00")` is `1234,50` and the invariant form is `1234.50`. It runs
in the browser, the same as the culture-less format. (`System.Globalization.CultureInfo` is not spelled out — Osy# has
no `System.` qualification anywhere.)

```osy title="a number for a file format, not for a reader" test app=stdlib-culture-invariant
app.DefaultCulture = "sv-SE";

string ForReader(decimal x) => x.ToString("0.00");
string ForFile(decimal x) => x.ToString("0.00", CultureInfo.InvariantCulture);
```

```osy title="the two answers, pinned" run app=stdlib-culture-invariant
[Test]
void Invariant_ignores_the_default() {
  Assert.Equal("1234,50", ForReader(1234.5m));
  Assert.Equal("1234.50", ForFile(1234.5m));
  Assert.Equal("1234.5", (1234.5m).ToString(CultureInfo.InvariantCulture));
}
```

**Reading the viewer's culture.** `Session.CurrentCulture` returns that effective token — the viewer's `[PreferredCulture]`
if set, else `app.DefaultCulture`, else `""` (invariant) — as a value, so you can branch on it (`if (Session.CurrentCulture
== "de-DE") …`) or pass it on. It is the author-facing sibling of `Session.CurrentUser`, resolves the same way as the
implicit formatting above, and runs in the browser with no round trip. C#'s own spellings read the same thing:
`CultureInfo.CurrentCulture` is `Session.CurrentCulture` — the culture a culture-less format uses, set per request as
ASP.NET's request localization sets it — and `CultureInfo.CurrentUICulture` is `Session.Language`, the language
translations are read in.

```osy title="C#'s spelling of the current culture" test app=stdlib-culture-current
app.DefaultCulture = "sv-SE";

string Price(decimal x) => x.ToString("N2", CultureInfo.CurrentCulture);
```

```osy title="the same answer as the culture-less format" run app=stdlib-culture-current
[Test]
void Current_is_the_default_here() {
  Assert.Equal((1234.5m).ToString("N2"), Price(1234.5m));
  Assert.Equal(Session.CurrentCulture, CultureInfo.CurrentCulture);
}
```

**Parsing (the inverse).** `Convert.ToDecimal(s, "sv-SE")` reads a number written in a culture (`"1 234,56"` → `1234.56`),
returning `0` for a string it can't parse (the `Convert.To*` contract). It runs **client-side too**, byte-identical to
the server, over a defined profile: leading/trailing whitespace, one sign, group separators, and a decimal separator.
Outside that profile — a currency symbol in the string, parentheses for a negative, an exponent — parses server-side (a
round trip). A culture-less `Convert.ToDecimal(s)` parses in the current culture (below) — in an app with no culture, invariantly.

`Convert.ToInt(s, "de-DE")` is the integer twin — it runs **client-side too**, over the same profile minus a decimal
point *and* minus a trailing sign (a `"1234-"` is a value for `ToDecimal` but `0` for `ToInt`). A value outside the
32-bit integer range parses to `0`, like every unparseable string.

**Parsing a date exactly.** `DateTime.ParseExact(s, "yyyy-MM-dd", "sv-SE")` reads a date/time written to a specific
pattern — the inverse of the date formatter. It is STRICT: a string that does not match the pattern *throws* (unlike the
number parses, which return `0`), exactly as .NET's `DateTime.ParseExact` does. It runs **client-side** for a literal
custom pattern that carries a full date — a 4-digit year (`yyyy`), a month, and a day-of-month — plus optional time,
using the supported tokens `y M d H h m s t` with separators and quoted literals; the field widths are strict (`MM`
wants two digits, `"2024-6-15"` is rejected), month/day names honour the culture (`MMMM`/`dddd`, genitive included), and
a day-name token must agree with the date. A **single-char standard specifier** (`"d"`, `"D"`), a **2-digit year**
(`yy` — its value depends on the culture's century window), or a **dynamic** pattern/culture stays server-side. The
lenient, multi-pattern `DateTime.Parse(s)` (no explicit pattern) also stays server-side.

### Everything that names no culture follows the current one   {#current-culture}
In C#, every culture-sensitive call that names no culture uses `CultureInfo.CurrentCulture`. Osy# does the same, on
the server, in the browser and in a query the database answers. In an app with `app.DefaultCulture = "sv-SE"`:

| You write | Answers | Because sv-SE… |
|---|---|---|
| `x.ToString()`, `Convert.ToString(x)`, `"n" + x`, `$"{x}"` (a number) | `1234,5` · `−2,5` | writes a comma, and U+2212 as its minus |
| `decimal.Parse(s)`, `int.Parse`, `long.Parse`, `double.Parse`, `decimal.TryParse` | `"1234,5"` → 1234.5; `"1234.5"` throws | reads a comma, and a `-` as its minus |
| `Convert.ToDecimal(s)`, `Convert.ToInt32(s)` | `"0,25"` → 0.25 | the same reading, 0 for text it cannot read |
| `s.ToUpper()`, `s.ToLower()` | `"istanbul"` → `"İSTANBUL"` under tr-TR | Turkish dots its capital I |
| `string.Compare(a, b)`, `a.CompareTo(b)`, `StringComparison.CurrentCulture` | `"ä"` after `"z"` | sorts å, ä, ö after z |
| `OrderBy(x => x.Name)`, `ThenBy`, `OrderByDescending` | `apple, Banana, zebra, år, äpple, öl` | the same order, in memory and as `ORDER BY` |

Each of these is C#'s answer under that culture. The explicit spellings stay what they say:
`CultureInfo.InvariantCulture`, `ToUpperInvariant()`, `StringComparison.Ordinal`, and
`OrderBy(x => x.Name, StringComparer.Ordinal)` for the code-point order (`StringComparer.OrdinalIgnoreCase` for that order
with case ignored). Reach for them where the text is read by a machine: an id, a key, a file format, a URL.

In an app that declares no culture, the current culture is the invariant one and nothing above changes: numbers write
a dot, a parse reads one, and text sorts by code point.

One culture-less call does NOT follow it yet: `DateTime.Parse(s)` (and `DateTime.TryParse`) reads the invariant
culture's forms, because .NET's lenient culture date parse has no browser twin and would pin every page that reads a
typed date to the server. Name the culture to read one — `DateTime.Parse(s, "sv-SE")` (server) or
`DateTime.ParseExact(s, "yyyy-MM-dd", "sv-SE")` (browser too).

A sort the database answers collates in the culture's language (`COLLATE "sv-x-icu"`), so an index on the column does
not serve it. `Contains`, `StartsWith` and `EndsWith` compare ordinally, as they did: a culture-aware match has no form
the database can run.

```osy title="what follows the current culture" test app=stdlib-culture-current-calls
app.DefaultCulture = "sv-SE";

string AsText(decimal x) => x.ToString();
decimal Read(string s) => decimal.Parse(s);
List<string> Sorted(List<string> names) => names.OrderBy(n => n).ToList();
List<string> ByCodePoint(List<string> names) => names.OrderBy(n => n, StringComparer.Ordinal).ToList();
```

```osy title="the answers, pinned" run app=stdlib-culture-current-calls
[Test]
void Culture_less_calls_follow_sv_SE() {
  Assert.Equal("1234,5", AsText(1234.5m));
  Assert.Equal(1234.5m, Read("1234,5"));
  Assert.Throws<FormatException>(() => Read("1234.5"));
  Assert.Equal(new List<string> { "apple", "Banana", "år", "öl" }, Sorted(new List<string> { "öl", "Banana", "år", "apple" }));
  Assert.Equal(new List<string> { "Banana", "apple", "år", "öl" }, ByCodePoint(new List<string> { "öl", "Banana", "år", "apple" }));
}
```

### A date joined into text   {#date-as-text}
A `DateTime` becomes text the way C# writes it, in the culture: `"at " + visit.At`, `$"at {visit.At}"`,
`visit.At.ToString()` and `Convert.ToString(visit.At)` all write `visit.At.ToString("G")` under the app's
`app.DefaultCulture` — or the viewer's `[PreferredCulture]` — so `05.06.2026 08:05:03` in German and
`6/5/2026 8:05:03 AM` in American English. A `DateOnly` writes its short date (`"d"`) and a `TimeOnly` its short time
(`"t"`), as C# does. It is the same text in a function, in the browser, and in a query over stored rows.

For a fixed, culture-free form, ask for one: `visit.At.ToString("O")` is the round-trip form and `"yyyy-MM-dd"` a
custom pattern. A query over stored rows writes the culture's pattern in the database for a Gregorian or Thai Buddhist
calendar and a pattern with no fraction of a second or offset — and so does a query sent to a data source, into which the
platform writes the pattern's parts and names. Any other calendar — `ar-SA`'s, `fa-IR`'s — or such
a pattern is refused when the query runs, with a message saying so; write that text after `.ToList()`.

## Examples       {#examples}

Format money and a percentage for an explicit culture:

```osy title="currency + percent" test app=stdlib-culture-formatting
string Price(decimal amount) {
  return amount.ToString("C", "sv-SE");     // "1 234,56 kr"
}

string Rate(decimal ratio) {
  return ratio.ToString("P1", "en-US");     // "12.5 %"
}
```

Format a date, standard and custom:

```osy title="dates" test app=stdlib-culture-formatting
string LongDate(DateTime when) {
  return when.ToString("D", "de-DE");        // "Freitag, 5. Januar 2024"
}

string Iso(DateTime when) {
  return when.ToString("yyyy-MM-dd", "sv-SE");
}
```

Parse a number a user typed in their culture (runs in the browser):

```osy title="culture parse" test app=stdlib-culture-formatting
decimal ReadPrice(string entered) {
  return Convert.ToDecimal(entered, "sv-SE");    // "1 234,56" → 1234.56, "" → 0
}

int ReadQuantity(string entered) {
  return Convert.ToInt(entered, "de-DE");        // "1.234" → 1234, "12,5" → 0 (no decimal point)
}

DateTime ReadDate(string entered) {
  return DateTime.ParseExact(entered, "dd/MM/yyyy", "en-GB");   // "25/12/2024"; a mismatch throws
}
```

Set an app-wide default so a culture-less format still localises:

```osy title="app default culture" test app=stdlib-culture-formatting
app.DefaultCulture = "sv-SE";

string LocalPrice(decimal amount) {
  return amount.ToString("C");               // uses sv-SE → "1 234,56 kr"
}
```

Let each viewer see prices in their own culture — mark a `Culture` property on the principal `[PreferredCulture]`:

```osy title="per-viewer culture" test app=stdlib-culture-formatting-viewer
app.DefaultCulture = "sv-SE";

[Principal] entity Member {
  [Required, MaxLength(60)] string Email;
  [PreferredCulture] Culture Locale;         // a BCP-47 token, e.g. "de-DE"
}

string ViewerPrice(decimal amount) {
  return amount.ToString("C");               // renders in the signed-in member's Locale, else sv-SE
}

string ViewerCulture() {
  return Session.CurrentCulture;             // the effective token: the member's Locale, else "sv-SE"
}
```

## See also       {#see-also}
- [DateTime](https://osysharp.com/reference/types/datetime/) — the date/time values these formats render
- [Encoding — Base64, hex, text encodings, URL, HTML](https://osysharp.com/reference/stdlib/encoding/) — the other C#-faithful text surfaces
