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 tokenDescription#
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), 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 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.
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#
- DateTime — the date/time values these formats render
- Encoding — Base64, hex, text encodings, URL, HTML — the other C#-faithful text surfaces