Osy#betaRun it on Osyrin: osyrin.com

Reference / Stdlib

Culture formatting — ToString(format, culture)

string value.ToString(format, culture) · value.ToString(format, CultureInfo.InvariantCulture) · app.DefaultCulture = "sv-SE"

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#.

stable11 examples compiled by CIculturelocaleformatcurrency

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:

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#

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#

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:

KindFormatsExample (sv-SE)
NumberN F D (+ digits: N2, F0)1 234,56
CurrencyC (+ digits)1 234,56 kr
PercentP (+ digits)12,5 %
Date/timed D t T g G M Y2024-01-05 · fredag 5 januari 2024
Custom numeric#,##0.00, 0.001 234,56
Custom dateyyyy-MM-dd, dd/MM/yyyy, MMMM d2024-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), 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). 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.)

app.DefaultCulture = "sv-SE";

string ForReader(decimal x) => x.ToString("0.00");
string ForFile(decimal x) => x.ToString("0.00", CultureInfo.InvariantCulture);
[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.

app.DefaultCulture = "sv-SE";

string Price(decimal x) => x.ToString("N2", CultureInfo.CurrentCulture);
[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#

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 writeAnswersBecause sv-SE…
x.ToString(), Convert.ToString(x), "n" + x, $"{x}" (a number)1234,5 · −2,5writes 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" throwsreads a comma, and a - as its minus
Convert.ToDecimal(s), Convert.ToInt32(s)"0,25" → 0.25the same reading, 0 for text it cannot read
s.ToUpper(), s.ToLower()"istanbul" → "İSTANBUL" under tr-TRTurkish dots its capital I
string.Compare(a, b), a.CompareTo(b), StringComparison.CurrentCulture"ä" after "z"sorts å, ä, ö after z
OrderBy(x => x.Name), ThenBy, OrderByDescendingapple, Banana, zebra, år, äpple, ölthe 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.

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();
[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#

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#

Format money and a percentage for an explicit culture:

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:

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):

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:

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]:

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#

Related

Encoding — Base64, hex, text encodings, URL, HTML

Encode and decode text — Base64, URL percent-encoding, and HTML escaping — with the C#-faithful spellings…

DateTime

A date and time. It is a wall-clock value, not an instant on a timeline, so it is never shifted by anybody's timezone…