This is the practical authoring guide for src/Humanizer/Locales/*.yml.
Read this first when you need to add a locale, change a locale, or migrate a locale off a residual runtime leaf. Together with Adding Or Updating A Locale, this document is intended to fully describe the allowed locale shape and authoring workflow without requiring contributors to inspect generator source code just to discover what belongs in a locale file.
Read Locale YAML Reference beside it when you need the exhaustive field and strategy inventory for a specific engine.
Each locale YAML file answers one question:
What locale-owned words, switches, lexical tables, and feature choices should the generator compile into runtime code for this locale?
Keep these boundaries strict:
- Locale YAML owns locale data.
- Shared runtime kernels own reusable algorithms.
- Generator C# owns the structural mapping from YAML to runtime constructors.
- Runtime never parses YAML or JSON.
If a value is a word, phrase, token, scale row, or strategy choice, it probably belongs in locale YAML.
If a value is describing generator plumbing, constructor shape, or how to walk the YAML tree, it does not belong in locale YAML.
Work through these questions in order.
- Is runtime culture fallback currently masking missing locale behavior? If yes, treat that as parity debt, not success. Fallback never counts as parity proof for a shipped locale.
- Is this a regional variant of an existing neutral locale?
If yes, create a child file with
variantOfand override only the differences. - Does the locale fit an existing shared engine? If yes, reuse that engine and provide locale-owned data.
- Does the locale need a new shared structural engine? Only add one if the behavior is actually reusable.
- Is the locale still genuinely procedural? Only then keep or add a residual locale leaf.
- Does your locale need month names, decimal separators, negative signs, or group separators that differ from what
CultureInfo.DateTimeFormat/NumberFormatInforeturns on the user's platform? If yes, author them incalendar:ornumber.formatting:so output is stable across .NET globalization modes and operating systems.
Each locale gets exactly one YAML file:
locale: 'en-US'
variantOf: 'en'
surfaces:
list:
engine: 'conjunction'
value: 'and'
number:
words:
engine: 'conjunctional-scale'
minusWord: 'minus'
parse:
engine: 'token-map'
normalizationProfile: 'LowercaseRemovePeriods'Supported top-level blocks are:
localevariantOfsurfaces
Supported surfaces members are:
listformatterphrasesnumberordinalclockcompasscalendar
Do not invent new top-level keys.
This skeleton shows the complete canonical locale shape. Every locale file must stay within this structure.
locale: '<locale>'
variantOf: '<parent-locale>'
surfaces:
list:
engine: '<list-engine>'
formatter:
engine: 'profiled'
phrases:
relativeDate:
now: '<text>'
never: '<text>'
past: {}
future: {}
duration:
zero: '<text>'
age:
template: '{value}'
dataUnits: {}
timeUnits: {}
number:
words:
engine: '<number-to-words-engine>'
parse:
engine: '<words-to-number-engine>'
formatting:
decimalSeparator: '<separator>'
negativeSign: '<sign>'
groupSeparator: '<separator>'
ordinal:
numeric:
engine: '<ordinal-engine>'
date:
pattern: '<pattern-with-{day}>'
dayMode: '<day-mode>'
dateOnly:
pattern: '<pattern-with-{day}>'
dayMode: '<day-mode>'
clock:
engine: '<clock-engine>'
compass:
full: []
short: []
calendar:
months: []
monthsGenitive: []
hijriMonths: []Notes:
formatterandphrasesare separate surfaces.clockis the canonical locale surface name even though the emitted runtime feature name istimeOnlyToClockNotation.numberandordinalare container surfaces; the actual owned blocks arenumber.words,number.parse,number.formatting,ordinal.numeric,ordinal.date, andordinal.dateOnly. Similarly,calendarcontainscalendar.months,calendar.monthsGenitive, andcalendar.hijriMonths.- A locale parity claim is invalid unless every canonical surface is explicitly accounted for as locale-owned or same-language inherited with proof. There is no shipped-locale exemption list in this repo.
- Do not add a block just to say "use the default behavior". If a surface or nested block does not carry locale-specific behavior, omit it.
Use variantOf when a locale is a true variant of another locale.
variantOf: 'en'Rules:
- Omitting a
surfaces.<surface>block inherits the whole surface from the parent locale. - Inside a mapped surface block, omitted scalar fields inherit from the parent mapping.
- Child sequences replace parent sequences.
- Child mappings merge with parent mappings.
- If the child changes
engine, the whole mapped surface is treated as a new block. - Inheritance is not self-proving. A parity claim still needs at least one locale-specific proving assertion for every inherited canonical surface.
- For parity work, do not rely on English fallback for any canonical surface.
- Do not use empty mappings as inheritance sentinels. Omission is the only default-behavior signal. Exception:
engine: 'default'is allowed onordinal.numeric,ordinal.date,ordinal.dateOnly, andclocksurfaces to explicitly opt into the built-in engine.
Use inheritance to express real parent-child relationships. Do not use it to hide unrelated locale behavior.
Most *Map fields are lexical tables. There are two valid authoring shapes.
Use a YAML sequence when every slot from zero upward is meaningful and you actually want to author all of them.
digitWords:
- 'zero'
- 'one'
- 'two'Use a numeric-slot mapping when the table starts at an offset, has intentional holes, or would otherwise require blank padding.
tensMap:
2: 'twenty'
3: 'thirty'unitsOrdinalPrefixes:
0: 'zeroth'
1: 'first'
3: 'third'Rules:
- Numeric keys are array indices.
- Missing slots compile to empty strings.
- Use a numeric key only when that index is intentionally meaningful.
- If the locale really needs a word at index
0, declare0:explicitly.
Do not author lexical tables with placeholder padding.
Use this block when the locale needs a generated collection joiner.
Put here:
- the collection formatter engine
- the locale-owned conjunction or delimiter token
Use this block for resource-key selection, time-unit gender metadata, data-unit fallback rules, and similar formatter-only grammar.
Put here:
- formatter strategy selectors
- resource key overrides
- grammatical metadata for units
- data-unit fallback handling
Do not put authored phrase tables here. Those belong under phrases.
Use this block when the locale needs authored human-readable strings for humanization surfaces.
Put here:
relativeDateRelative date phrases such asnow,never, and per-unitpastandfutureformsdurationTimeSpan.HumanizeandToAgephrases, includingzero,age.template, and per-unitsingleandmultipleformsdataUnitsHumanized data-unit names and symbolstimeUnitsHumanized time-unit symbols and labels
This is a first-class canonical surface. Do not collapse it into formatter.
Use this block when the locale owns or inherits cardinal or ordinal number rendering through a shared runtime kernel.
Put here:
- lexical tables such as
unitsMap,tensMap,hundredsMap - scale rows
- conjunction and separator words
- grammatical strategy values required by the engine
Supported render engines in current checked-in YAML include:
agglutinative-ordinal-scaleappended-groupbillion-strategyconjoined-gendered-scaleconjunctional-scaleconstruct-state-scalecontextual-decimalcontracted-one-scaleeast-asian-groupedeast-slavicgendered-scale-ordinalharmony-ordinalhyphenated-scalehyphenated-ordinalindian-groupingindian-grouping-genderedindian-scale-formsinverted-tensjoined-scalelinking-scalelinked-vigesimallong-scale-stem-ordinaldual-form-scaleordinal-prefix-scalepluralized-scalescale-leading-compoundstemmed-scalescale-strategysegmented-scalesouth-slavic-cardinalterminal-ordinal-scaletriad-scaleunit-leading-compoundvariant-decadewest-slavic-gendered
Use this block when the locale supports parsing written numbers.
Put here:
- exact token maps
- scale token lists
- normalization settings
- negative prefixes and ignored tokens
Supported parse engines in current checked-in YAML include:
compound-scalecontracted-scaleeast-asian-positionalgreedy-compoundinverted-tenslinking-affixlinked-vigesimalscale-leading-compoundprefixed-tens-scalestemmed-scalesuffix-scaletoken-mapvigesimal-compound
For locale parity work, account for number.parse alongside number.words so the locale can naturally round-trip the same high-range forms in both directions.
stemmed-scale is available as a narrow render/parse pair for additive count+scale morphology where bound count stems attach to scale suffixes. Use it only when the locale's structure is still additive scale decomposition; do not stretch it to vigesimal, subtractive, or clock-like number systems.
linked-vigesimal is available for locales with lexicalized linked/vigesimal low numbers plus scale-leading higher values. Use it when a dense low-number table and scale count overrides are the smallest accurate model; do not use it for bound count stems that belong in stemmed-scale.
Use this block when the locale ordinalizes numeric forms directly.
Put here:
- suffix templates
- modulo rules
- gendered ordinal templates
The nested forms are:
ordinal.numericordinal.dateordinal.dateOnly
Use this block when the locale has generated clock-phrase output.
Put here:
- phrase templates for rounded or relative clock output
- period-of-day words when the engine uses them
Supported engine:
phrase-clock— the unified clock engine all shipped locales resolve to directly or through locale inheritance
Use this block when the locale owns heading or compass labels.
Put here:
fullThe full 16-point heading labelsshortThe abbreviated 16-point heading labels
Use this block when the locale needs month names that differ from what CultureInfo.DateTimeFormat.MonthNames returns on the user's platform. This is typically needed when platform globalization data differs across platforms, target frameworks, or globalization sources (ICU vs NLS), or when the platform-supplied names are incorrect for the locale.
Put here:
monthsArray of exactly 12 nominative month names, indexed by Gregorian month (0 = January)monthsGenitiveOptional parallel array of 12 genitive month names for locales that distinguish nominative and genitive formshijriMonthsOptional array of exactly 12 Islamic (Hijri) month names (0 = Muharram). Used when the culture's calendar is Hijri/UmAlQura andordinal.date.calendarModeisNative
Do not author calendar: when CultureInfo already returns the correct month names on all platforms. The block is an override, not a requirement. Author hijriMonths when the locale uses the Islamic calendar and needs locale-specific Hijri month name rendering in date ordinals.
Use this block when the locale needs a decimal separator, negative sign, or group separator that differs from what NumberFormatInfo returns on the user's platform. This is the "output as digits" complement to number.words (output as words) and number.parse (input).
Put here:
decimalSeparatorThe locale-correct decimal separator characternegativeSignThe locale-correct negative sign character (e.g., U+2212 minus sign for Nordic/European locales where NLS returns U+002D hyphen-minus)groupSeparatorThe locale-correct thousands group separator character (e.g., period for lb-LU where NLS returns a space)
Do not author number.formatting: when NumberFormatInfo already returns the correct values on all platforms.
Reuse an existing engine when:
- the locale differs only in words
- the locale differs only in lexical tables
- the locale differs only in scales
- the locale differs only in grammatical metadata
- the locale differs only in strategy enum choices
Add a new shared engine only when:
- the algorithm is structurally reusable
- at least two locales can share it, or one locale plus an obvious second target already exists
- the YAML shape stays coherent
- the runtime stays parse-free and benchmark-safe
Keep a residual locale leaf only when forcing it into YAML would add imperative hooks or an exception-bucket profile.
When you are building a locale from scratch, use this order:
- Add
variantOffirst if the locale is a regional variant. - Add
listonly if list joining actually differs from the parent. - Add
formatteronly if formatter resource selection or unit grammar differs. - Add
phrasesonce you know the locale-owned relative-date, duration, data-unit, and time-unit strings. - Add
number.wordsonce you know the render-side engine family. - Add
number.parseonce you know the parse-side engine family. - Add
ordinal.numericif numeric ordinalization exists independently fromnumber.words. - Add
ordinal.dateorordinal.dateOnlyonly for date-specific day phrasing. - Add
clockusing the unifiedphrase-clockengine. All shipped locales use this single engine. - Add
compassif the locale needs heading labels and does not inherit acceptable same-language values. - Add
calendaronly if platform-supplied month names disagree across platforms or target frameworks, or are incorrect. - Add
number.formattingonly if the decimal separator, negative sign, or group separator disagrees across platforms or target frameworks, or is incorrect.
This keeps authoring pressure on the generated/shared surfaces first and makes it easier to spot when a new block is really necessary.
- Create
src/Humanizer/Locales/<locale>.yml. - Produce a preflight gap report covering every canonical surface.
- Add or prove every canonical surface through locale ownership or same-language inheritance with proof.
- Reuse existing engines wherever possible.
- Add runtime tests under
tests/Humanizer.Tests/Localisation/<culture>. - Add generator assertions if the generated wiring changed.
- Maintain a parity map artifact until the unresolved set is empty.
- Create
src/Humanizer/Locales/<locale>.yml. - Set
variantOfto the parent locale. - Override only the fields that truly differ.
- Do not copy the parent block unless the engine itself changes.
- Identify the actual rule family, not the locale name.
- Move locale-owned words and switches into YAML.
- Reuse or extend a shared runtime kernel.
- Keep the engine name structural if the implementation is truly shared.
- Add parity tests and run benchmarks before removing the leaf.
Use this recipe when a cross-platform or cross-target-framework probe shows that CultureInfo returns different month names, decimal separators, negative signs, or group separators on different platforms or target frameworks (e.g., net48/NLS vs net8+/ICU on Windows) for your locale.
Month-name override (minimum YAML):
surfaces:
calendar:
months:
- 'January'
- 'February'
- 'March'
- 'April'
- 'May'
- 'June'
- 'July'
- 'August'
- 'September'
- 'October'
- 'November'
- 'December'Replace the English names with the correct month names for your locale. Add monthsGenitive: only if your locale distinguishes nominative and genitive month forms.
Decimal-separator override (minimum YAML):
surfaces:
number:
formatting:
decimalSeparator: '.'Replace '.' with the correct separator for your locale.
Negative-sign override (minimum YAML):
surfaces:
number:
formatting:
negativeSign: '−' # U+2212 minus signUse this when ICU/CLDR specifies U+2212 (minus sign) but Windows NLS returns U+002D (hyphen-minus) for your locale. This is common for Nordic and European locales (fi, sv, nb, nn, hr, sl, lt). The override ensures that negative numbers rendered by OrdinalizeExtensions and ByteSize.ToString use the typographically correct minus sign on all platforms.
Group-separator override (minimum YAML):
surfaces:
number:
formatting:
groupSeparator: '.'Use this when NLS and ICU disagree on the thousands separator for your locale. For example, lb-LU where NLS returns a space but CLDR specifies a period as the group separator. The override ensures that ByteSize.ToString uses the locale-correct thousands separator on all platforms.
All formatting overrides are consumed by culture-aware Ordinalize int overloads (formatting only), byte-size string formatting (ByteSize.ToString and ByteSize.ToFullWords), and MetricNumeralExtensions. String Ordinalize overloads parse with the culture's native NumberFormatInfo (no overrides). They do not modify the global CultureInfo. ByteSize.TryParse applies only the decimal separator override, and only when an explicit CultureInfo is passed as the format provider; it does not use negativeSign or groupSeparator overrides.
Every localization change should end with:
dotnet test tests/Humanizer.SourceGenerators.Tests/Humanizer.SourceGenerators.Tests.csproj --framework net10.0
dotnet test tests/Humanizer.Tests/Humanizer.Tests.csproj --framework net10.0
dotnet test tests/Humanizer.Tests/Humanizer.Tests.csproj --framework net8.0
dotnet pack src/Humanizer/Humanizer.csproj -c Release -o artifacts/plan-validationIf the change touches a hot runtime path, also run the relevant benchmark suite.
For parity-sensitive locale work, the practical completion test is stronger than “the YAML compiled”:
- every canonical surface is intentionally locale-owned or intentionally inherited through
variantOfwith proof - the locale does not rely on English fallback or unsupported-locale behavior for any shipped localized surface
- exact-output tests exist for grammar-sensitive or locale-specific behavior
- the parity artifact ends with an empty unresolved set
If you cannot produce an empty unresolved set for the locale, you must report parity not complete.