# ICU Divergences

Localize is tested against conformance data that Unicode generates by running **ICU**. Most of the time the two agree and a mismatch means Localize has a defect. Sometimes they do not agree, because ICU is an implementation with its own API surface and defaults, not the specification itself. This guide records where Localize deliberately differs from ICU, why, and how each case can be checked.

## The rule

**Where the format CLDR ships and ICU's behaviour conflict, Localize follows CLDR.**

CLDR is the data. ICU is one consumer of it, and a conformance file generated from ICU records ICU's answer — including where ICU overrides, ignores, or supplements what the data says. Localize renders what the locale's data specifies, so a locale maintainer who changes a pattern in CLDR sees that change in Localize's output.

This is not a claim that ICU is wrong. In several cases below ICU's output is arguably the friendlier one. It is a statement about which source is authoritative when they disagree, so that the answer is predictable rather than decided case by case.

## Distinguishing a divergence from a defect

Four different things can make a conformance case fail, and only the first is a divergence:

* **Divergence** — CLDR's data says one thing, ICU does another, and Localize follows CLDR. Recorded, asserted, and never "fixed".
* **Contract mismatch** — the fixture was generated through an ICU API whose defaults differ from the CLDR pattern's. Not a divergence: the test has to ask the same question ICU was asked.
* **Upstream fixture issue** — the conformance file itself disagrees with CLDR's own data.
* **Defect** — Localize is wrong. Fix it.

The distinction matters because they are easy to confuse, and mislabelling a defect as a divergence hides a bug behind a comment. Before recording anything as a divergence, read the locale's data and confirm that Localize is rendering what CLDR ships.

### Contract mismatch, in practice

CLDR 49's decimal conformance suite is generated by `org.unicode.cldr.tool.GenerateDecimalFormatTestData`, which builds an ICU `NumberFormatter` with **no explicit precision**. `NumberFormatter`'s default is up to six fraction digits, where the CLDR decimal pattern `#,##0.###` allows three. Run the fixtures against the pattern default and thousands of cases fail — none of them because the formatting is wrong.

`test/localize/number/decimal_conformance_test.exs` therefore passes `max_fractional_digits: 6` for the standard dimensions, matching the generator. This is why the generator source is worth reading before wiring up any new conformance suite: the fixture encodes the API call that produced it, not just the expected strings.

## Recorded divergences

### Number formatting

Enumerated in `@divergences` in `test/localize/number/decimal_conformance_test.exs`, which asserts the **count** of each rather than merely skipping them — a divergence that resolves upstream, or one that grows, moves the number and the test reports it.

| Locales | Dimension | CLDR ships | ICU renders |
|---|---|---|---|
| `gu`, `hi`, `hi-Latn`, `lo`, `mr`, `pa`, `si` | scientific | the pattern `[#E0]`, brackets included | ignores the locale pattern and builds the notation itself |
| `ur` | percent | `symbols-numberSystem-latn.percentSign` is `٪` (U+066A) | `%`, because the generator formats percent as a measure unit and `ur`'s unit pattern is `{0}%` |
| `gl` | decimal, percent | group separator U+00A0 | `.` |
| `bn` | compact long | `00000 কোটি`, carrying no grouping | applies the locale's standard grouping to the mantissa |
| `af` | compact short | `0 m'.'`, whose quoted literal is a full stop | drops the full stop |
| `en-IN`, `hi-Latn` | compact long | `0 million` | `12 lakh` |
| `ig`, `ps`, `ta` | compact | localised patterns `0 nde`, `0 میلیونه`, `0M` | root forms, or no compact form at all |

The `ur` case is the subtlest: both `٪` and `%` come from CLDR, one from the number symbols and one from the unit data. Localize formats percent through the number pattern, so it uses the symbol; the generator formats it as a measure unit, so it uses the unit pattern.

### Where ICU4C is used directly

`Localize.Nif.number_format/3` is ICU4C's `NumberFormatter` and therefore produces ICU's answers, not Localize's, for every case in the table above. The NIF is built for cross-validation and for collation sort keys, not as a drop-in for `Localize.Number.to_string/2`; the two are not expected to agree in these locales. See the NIF section of the [number formatting guide](number_formatting.md).

### Date and time patterns

TR35 is CLDR's specification, and the rule applies to it as to the data. Its Date Field Symbol Table lists the widths each pattern letter takes, and its Handling Invalid Patterns recommends U+FFFD for a field at any other width and an error for a letter the table does not define. ICU4C 78.3 does neither; Localize follows TR35, asserted in `test/localize/datetime/invalid_field_length_test.exs`.

| Pattern | TR35 and Localize | ICU4C renders |
|---|---|---|
| A numeric field past its widths: `dddd`, `MMMMMM`, `HHH` | U+FFFD | the number zero-padded to the width, "0006" |
| A name field past its widths: `GGGGGG`, `EEEEEEE`, `aaaaaa` | U+FFFD | a defined width: "AD", "Sun", "AM" |
| `zzzzz` and `ZZZZZZ` | U+FFFD | the long forms, "Eastern Daylight Time" and "GMT-04:00" |
| Other zone widths TR35 does not list: `OO`, `vv`, `xxxxxx` | U+FFFD | nothing |
| An undefined letter such as `n` | an error | nothing |

Week numbers (`Y`, `w`, `W`) are the calendar's own for a calendar that numbers its weeks (user, 2026-10-01), where ICU4C numbers every calendar's weeks by the locale's `firstDay` and `minDays`. `Calendar.ISO` has no weeks of its own, so Localize numbers its weeks by the locale's week data as ICU4C does (user, 2026-10-02): `Y`, `w` and the numeric weekday `e` agree with ICU4C 78.3 on every day of 2019 to 2030 in seventeen locales, the `-u-fw-`, `-u-rg-`, `-u-sd-` and `-u-ca-iso8601` keys included.

`W` differs at a month's ends, and Localize keeps the difference (user, 2026-10-02). TR35 says a month's weeks are "similarly calculated" to a year's, with `minDays` "the minimum number of days in the new month for a week to count as part of that month". So Localize gives a week to the month that holds at least the locale's minimum days of it, and a pattern with `W` writes its month and year as the week's, as `Y` writes the year `w` belongs to. ICU4C numbers a week within its date's own month: 0 for the days before the month's week 1, and 5 or 6 for the days after its last whole week. `MMMMW` is "'week' W 'of' MMMM" in both locales:

| Date | Locale | Localize | ICU4C 78.3 |
|---|---|---|---|
| 1 October 2021 | `en-GB` | "week 5 of September" | "week 0 of October" |
| 30 September 2021 | `en` | "week 1 of October" | "week 5 of September" |
| 30 June 2026 | `en` | "week 1 of July" | "week 5 of June" |
| 30 June 2026 | `en-GB` | "week 1 of July" | "week 5 of June" |

The first row is a week holding four days of September under ISO 8601's weeks, and the others are weeks holding enough days of the month after: one in `en`, four in `en-GB`. Wherever the week is in its date's own month the two agree, which over 2019 to 2030 is every day but 432 of 4,383 in `en` and every day but 246 in `en-GB`. Localize's values are asserted from TR35's rule for every day of 2015 to 2026 in `test/localize/calendar_callbacks_test.exs`.

The extended year (`u`) is the calendar's own answer, its `extended_year/3`. TR35 calls it "a single number designating the year of this calendar system, encompassing all supra-year fields", with the Julian calendar's 1 BCE as year 0 for its example and 4601 for its sample value. ICU4C 78.3 writes the same number in the calendars whose years it counts as Calendrical does: the Gregorian, Julian, Japanese, Coptic, Ethiopic, Hebrew, Indian, Persian and Islamic calendars. In four others it writes a Gregorian year, where Localize writes the calendar's own (user, 2026-10-02), asserted in Calendrical's `test/localize_extended_year_test.exs`:

| Calendar | Localize, 15 June 2026 | ICU4C 78.3 |
|---|---|---|
| Buddhist | 2569 | 2026, the Gregorian year |
| ROC | 115 | 2026, the Gregorian year |
| Chinese | 4663 | 2026, the related Gregorian year `r` writes |
| Dangi | 4359 | 2026, the related Gregorian year `r` writes |

The year `y` writes in those last two calendars is no difference: Localize and ICU both write the year's place in the sixty-year cycle. TR35's `U` names "the year value" and, where it has no name for it, is written as `y` writes it, so the two letters write one number. ICU4C 78.3 writes the Chinese and Dangi year that began in 2026 as 43 with `y` and 0043 with `yyyy`, and 16 June 2026 in `ko`'s Dangi short date "y. M. d." as "43. 5. 2.", and so does Localize (user, 2026-10-04: follow TR35), asking the calendar's `cyclic_year/3` where the locale's data names the calendar's years by a cycle; asserted in `test/localize/date_parse_lunisolar_test.exs`. It is `u` alone that differs, the year's number here and a Gregorian year there.

The related year (`r`) keeps Latin digits whatever digits the rest of the date is written in. TR35 says of it that it "is usually displayed using the "latn" numbering system, regardless of what numbering systems may be used for other parts of the formatted date". ICU4C 78.3 writes it in the locale's digits, so the fifth month of the Chinese year that began in 2026 at root's `yyyyM`, "r-MM", in `ar-EG` is "2026-٠٥" here and "٢٠٢٦-٠٥" there, and so in every locale that writes its dates in digits of its own; asserted in `test/localize/datetime/cyclic_year_symbol_test.exs`.

A Hebrew year in Hebrew numerals keeps its thousands, as CLDR's `hebr` numbering system writes any number: 5786 is "ה׳תשפ״ו". TR35 has a pattern's `numbers` attribute render its "numeric quantities" in the numbering system named, and says nothing of shortening a year. ICU4C 78.3 takes 5,000 from a year of the current millennium before it writes it, "תשפ״ו", and writes 4999 in full, "ד׳תתקצ״ט"; Localize reads both. Asserted in `test/localize/hebrew_numeral_date_parse_test.exs` and `test/localize/interval_calendar_test.exs`.

A skeleton is matched against the locale's `availableFormats` alone, as CLDR's own skeleton conformance data (`common/testData/datetime/skeletons.tsv`) is made. TR35 has the list a skeleton is matched against hold "`dateFormatItem` elements as well as the predefined patterns", and ICU4C 78.3 adds the standard date and time formats' patterns to it: `pl`'s Chinese `yMd` is root's `yyyyMd`, "2026-05-02", here and "2.5.43" there, its short date "dd.MM.y" being the nearer. CLDR's reference generator has those patterns behind a `useStock` flag its conformance data is made without, and with them 43 of that data's 279 locales fail (`ru`'s `yMdd` becomes "06.7.2024"), so Localize keeps to the data (user, 2026-10-06); asserted in `test/localize/datetime/skeleton_conformance_test.exs`.

A skeleton for a date of another calendar is matched against that calendar's formats and then against the Gregorian calendar's. TR35 has most calendars "inherit general date format data (in the `<dateFormats>` and `<dateTimeFormats>` elements) from the "generic" calendar format data in the same locale, which in turn inherits from Gregorian", and appends the fields no format carries to "the one with the greatest number of matching fields (but no extra fields)". CLDR's root data has no alias from the generic calendar's formats to the Gregorian ones, and ICU4C 78.3 follows the data: `en`'s Hebrew `yw` is the Gregorian format here, "week 39 of 5786", and the calendar's year with the week appended there, "5786 (week: 39)"; `Yw` is the same format here and "39 5786 AM" there; and `ywE` is "Tue, week 39 of 5786" here, where ICU writes no weekday. Where the calendar has a format of the other fields the two agree: `yMMMdw` is "1 Tamuz 5786 (week: 39)" in both. The Chinese and Dangi calendars, which TR35 excepts, are matched against their own formats alone (user, 2026-10-06), so their `yw` is "2026(bing-wu) (week: 18)" in both; of two formats that lack as many fields TR35 ranks the one with the month before the one with the week, so their `MMMMW` is "Fifth Month (week: 1)" here and "1 (month: Fifth Month)" there. Asserted in `test/localize/datetime/append_items_test.exs`.

### Date parsing

TR35 makes `y` the year of the era `G` names, and leaves the reading of a two-digit year to the implementation. ICU4C 78.3 reads any two-digit year as one within 80 years before and 20 after today, even beside an era, so it cannot read back the "44 BC" it formats itself. Localize takes a year its era qualifies as written where the format writes the year in full (`y`), and applies the pivot to a year written without an era and, as ICU does, to one the format writes as `yy`, its two low-order digits; asserted in `test/localize/date_parse_era_test.exs` and `test/localize/date_parse_lunisolar_test.exs`.

| Input in `en` | TR35 and Localize | ICU4C parses |
|---|---|---|
| "Jun 1, 44 BC" | 44 BC, `~D[-0043-06-01]` | 2044 BC |
| "Jun 1, 44 AD" | AD 44, `~D[0044-06-01]` | 2044 |

The two agree on everything else here: "Jun 1, 1 BC" is year 0, and "Jun 1, 44", with no era, is 2044.

A week the year does not have is an error in Localize and a later week in ICU4C, and Localize keeps the difference (user, 2026-10-02). ICU4C's parser is lenient: in `en`, where 2026 has 52 weeks, it reads "week 53 of 2026" as the week beginning 27 December 2026, which is week 1 of 2027, and "week 60 of 2026" as a week of February 2027. TR35's note on week data recommends the first of those readings, saying a week 53 parsed for a year without one "should be treated as in the first week of the following year". Localize reads only a week the year has, in the weeks it writes, and returns a `Localize.DateParseError` for any other, as it does for every field value no date has, so text that names no week is reported rather than moved.

| Input | Locale | Localize | ICU4C 78.3 parses |
|---|---|---|---|
| "week 53 of 2022" | `en` | `~D[2022-12-25]` | 25 December 2022 |
| "week 53 of 2026" | `en` | an error | 27 December 2026 |
| "week 60 of 2026" | `en` | an error | 14 February 2027 |
| "Woche 53 des Jahres 2026" | `de` | `~D[2026-12-28]` | 28 December 2026 |
| "Woche 53 des Jahres 2025" | `de` | an error | 29 December 2025 |

`Calendar.ISO`'s weeks are the locale's, so the weeks a year has depend on the locale: 2026 has 53 weeks in `de` and 52 in `en`. Asserted in `test/localize/impossible_fields_parse_test.exs`.

### Time parsing

TR35 has a flexible day period (`B`) the locale gives no name formatted with its AM and PM names, and ICU4C 78.3 formats it so, but it does not read those names back in a `B` field: `oc`, which has no day-period rules, writes 23:59 in its `Bhm` pattern "h:mm B" as "11:59 PM", which ICU cannot parse. Localize reads a `B` field's AM and PM names as `a` reads them, so the time parses back; asserted in `test/localize/time_parse_test.exs`.

TR35's time zone parsing reads a zone written in any form — a specific or generic name, a location, a city, a zone ID or the localized GMT format — and ICU4C 78.3's `TimeZoneFormat` does too, but its date formats read in a zone field only the forms of that field's letter and the GMT format: "Eastern Time" in the `zzzz` field of `en`'s full time is a parse error. Localize reads every form in every zone field, as TR35's process does, and reads a name whatever apostrophe or spacing it is written with, as TR35 allows a parse to ("heure d'été de l'Est nord-américain" with ASCII apostrophes), where ICU requires the name's own; asserted in `test/localize/zone_parse_test.exs`.

| Input in `en` | TR35 and Localize | ICU4C parses |
|---|---|---|
| "10:00:00 AM Eastern Time" in `h:mm:ss a zzzz` | 10:00 in New York | an error |
| "10:00:00 AM New York Time" in `h:mm:ss a z` | 10:00 in New York | an error |

Reading every form in every field, Localize reads a string one way where ICU reads it by the field. `en` names the Kyrgyzstan metazone "Kyrgyzstan Time" and nothing else, which is Bishkek's location format too; by TR35's type fallback a metazone with no daylight name needs none and its standard name stands for all three types, so Localize reads it as Bishkek's own time in the summers the zone kept daylight time. ICU4C 78.3 reads it so in a `VVVV` or `vvvv` field, and as standard time, an hour later, in a `zzzz` field; asserted in `test/localize/zone_parse_test.exs`.

| "1990-07-15 10:00:00 Kyrgyzstan Time" | Localize | ICU4C parses |
|---|---|---|
| in `y-MM-dd HH:mm:ss VVVV` | 03:00 UTC | 03:00 UTC |
| in `y-MM-dd HH:mm:ss zzzz` | 03:00 UTC | 04:00 UTC |

TR35's parsing reads the localized GMT format with "non-Latin numbers", and ICU4C 78.3 reads any of Unicode's decimal digits in it, in any locale. Localize reads the digits of every CLDR numbering system in any locale, among them those of `hanidec`, which are no decimal digits to Unicode and which ICU reads only where the locale asks for them. It reads an ISO 8601 offset in other digits too, which TR35 does not speak of and ICU reads in 0 to 9 alone: Localize's date and time parsers read all of a time in the locale's digits, its offset with it, and the zone reader reads the same text alone. Asserted in `test/localize/zone_parse_test.exs`.

| Input | Localize | ICU4C parses |
|---|---|---|
| "GMT+٠٥:٣٠" in `en` | +05:30 | +05:30 |
| "GMT+五:三〇" in `en` | +05:30 | an error |
| "+٠٥:٣٠" in `en` or `ar-EG` | +05:30 | an error |

TR35's parsing has the localized GMT format matched leniently: its number as "03, 3, 330, 3:30, 33045 or 3:30:45", with "+, -, or nothing" before it and spaces after "GMT", after the sign and before the number. ICU4C 78.3 reads the number as the locale's `hourFormat` writes it, so in `en` it wants the sign, the colons and no spaces, and reads digits with nothing between them only in a locale that writes them so, as `am` does. Localize reads each of TR35's forms in every locale. A number with no sign is an offset east of the literal it follows, in a zone read on its own (`Localize.DateTime.Timezone.parse_zone/2`, `parse_offset/2`, `resolve/3`), which is how TR35 describes its parsing; a zone that is a field of a date or time keeps needing the sign, since the number after it can be the year or the hour that follows ("12:00:00 UTC 2026", `zh`'s "GMT 12:00:00"), and a number before a literal that follows it needs one everywhere, being as much a time of day ("10:30 GMT"). Both write an offset's seconds, which TR35 makes an optional field without saying where it goes: after the minutes, behind what the locale's `hourFormat` has between its hours and its minutes, as ICU places them. Asserted in `test/localize/zone_parse_test.exs` and `test/localize/datetime/timezone_format_test.exs`.

| Input in `en` | TR35 and Localize | ICU4C parses |
|---|---|---|
| "GMT+3:30:45" | +03:30:45 | +03:30:45 |
| "GMT+33045" | +03:30:45 | an error |
| "GMT+330" | +03:30 | an error |
| "GMT +3" | +03:00 | an error |
| "GMT 3" | +03:00 | an error |

TR35 gives each hour field its range, `h` 1 to 12, `H` 0 to 23, `K` 0 to 11 and `k` 1 to 24, and its parsing notes take a number beyond a field's range for no value of that field. ICU4C 78.3's parser, lenient unless told otherwise, carries such an hour on into the hours that follow, and into the next day where they run out; told not to be lenient, it refuses it. Localize refuses it, and refuses the three values at a field's edge that TR35's ranges leave out: `k`'s 0 and `h`'s 0, which ICU reads as midnight either way, and `K`'s 12, which ICU reads as half past noon when it is lenient. Asserted in `test/localize/time_parse_test.exs`.

| Input | Pattern | Localize | ICU4C, lenient | ICU4C, not lenient |
|---|---|---|---|---|
| "13:30 PM" | `h:mm a` | an error | 01:30 the next day | an error |
| "13:30 AM" | `h:mm a` | an error | 13:30 | an error |
| "24:00" | `H:mm` | an error | 00:00 the next day | an error |
| "0:30" | `k:mm` | an error | 00:30 | 00:30 |
| "12:30 AM" | `K:mm a` | an error | 12:30 | an error |
| "0:30 AM" | `h:mm a` | an error | 00:30 | 00:30 |

TR35 says a 24-hour pattern should have no day period, and that in parsing "the dayperiod is checked for consistency with the hour". One pattern in CLDR 49 has both, `ksh`'s `Hmsv`, "H:mm:ss a v". Localize reads a 24-hour hour beside a day period where the two agree and refuses it where they do not, which leaves "4:00:00 n.M.", four in the afternoon, to the 12-hour pattern that wrote it. ICU4C 78.3 takes the day period and drops the hour, lenient or not. Asserted in `test/localize/time_parse_test.exs`.

| Input | Pattern | Localize | ICU4C |
|---|---|---|---|
| "16:00 PM" | `H:mm a` | 16:00 | 12:00 |
| "4:00 PM" | `H:mm a` | an error | 12:00 |
| "4:00 AM" | `H:mm a` | 04:00 | 00:00 |
| "16:00 AM" | `H:mm a` | an error | 00:00 |

### Time zone formatting

TR35's generic non-location format (`v`, `vvvv`) qualifies a metazone name, unless the zone is the metazone's preferred zone for the locale's country, with the zone's country where it is that country's preferred zone and with its city otherwise; CLDR's own `TimezoneFormatter` does exactly that. ICU4C 78.3 qualifies a metazone name only where the zone's offset at that moment differs from the preferred zone's, and writes a zone keeping no daylight time within 184 days by its standard name, so even TR35's worked example "Pacific Time (Canada)" for Vancouver in `en_MX` is "Pacific Time" in ICU. Localize follows TR35, and both read back to the same instant; asserted in `test/localize/datetime/generic_zone_format_test.exs`.

| `vvvv` in `en`, July 2023 | TR35 and Localize | ICU4C formats |
|---|---|---|
| `Europe/Berlin` | Central European Time (Germany) | Central European Time |
| `America/Vancouver` | Pacific Time (Canada) | Pacific Time |
| `America/Phoenix` | Mountain Time (Phoenix) | Mountain Standard Time |
| `America/New_York` in `en-JM`, January | Eastern Time (United States) | Eastern Time |

The specific format (`z`, `zzzz`) takes the same steps, which TR35 gives for "the non-location formats (generic or specific)", and CLDR's `TimezoneFormatter` qualifies a specific name as it does a generic one. ICU4C 78.3 never qualifies a specific name, so Vancouver's and Los Angeles's are one string. Localize follows TR35 and CLDR's formatter (user, 2026-10-04), and a qualified name reads back as its own zone; asserted in `test/localize/datetime/specific_zone_format_test.exs`.

| `zzzz` in `en`, July 2026 | TR35, CLDR's formatter and Localize | ICU4C formats |
|---|---|---|
| `Europe/Berlin` | Central European Summer Time (Germany) | Central European Summer Time |
| `America/Vancouver` | Pacific Daylight Time (Canada) | Pacific Daylight Time |
| `America/Phoenix` | Mountain Standard Time (Phoenix) | Mountain Standard Time |
| `Africa/Abidjan` | Greenwich Mean Time (Côte d’Ivoire) | Greenwich Mean Time |

Where a locale has no name for a zone, Localize and ICU both write the localized GMT format, the short one for `z` and the long one for `zzzz`. TR35 49's symbol table gives `zzzz` the short one, while its text on semantic skeletons has the offset follow the length; Localize keeps the long, as ICU4C and ICU4X write it (user, 2026-10-04). CLDR's `TimezoneFormatter` is not followed there: it writes a region format ("Jordan Time"), which TR35 49 removed, for a long name and the long GMT format for a short one.

TR35's location format (`VVVV`) names a zone's country by "short country name, if it exists, otherwise the country name". ICU4C 78.3 writes the full name, so the two differ wherever a locale gives such a country a short name. Localize follows TR35 and reads either name back; asserted in `test/localize/datetime/location_zone_format_test.exs`. Both write the code of a country the locale does not name ("CU" for Havana's zone in `su`).

| `VVVV` in `en` | TR35 and Localize | ICU4C formats |
|---|---|---|
| `Europe/London` | UK Time | United Kingdom Time |
| `Asia/Hong_Kong` | Hong Kong Time | Hong Kong SAR China Time |
| `Asia/Yangon` | Myanmar Time | Myanmar (Burma) Time |
| `Europe/Sarajevo` | Bosnia Time | Bosnia & Herzegovina Time |

### Interval formatting

An interval takes the formats of its endpoints' calendar. ICU4C 78.3 differs from Localize in eleven places, asserted in `test/localize/interval_calendar_test.exs`, `test/localize/interval_skeleton_test.exs`, `test/localize/interval_closest_match_test.exs`, `test/localize/interval_standard_format_test.exs` and `test/localize/date_parse_lunisolar_test.exs`; the third follows ECMA-402 and the others TR35.

| Interval | Localize | ICU4C renders |
|---|---|---|
| `nl` Buddhist medium, 10:00 to 10:30 on 1 April 2566 | "1 apr 2566 BE 10:00:00 – 1 apr 2566 BE 10:30:00" | "1 apr 2566 BE, 10:00:00 – 10:30:00" |
| `en` `yMMMdHms`, 12:00:05 to 14:30:07 on 8 April 2026 | "Apr 8, 2026, 12:00:05 – Apr 8, 2026, 14:30:07" | "Apr 8, 2026, 12:00:05 – 14:30:07" |
| `en` Chinese medium, `rMMMd` | "Mo2bis 11 – 20, gui-mao" | "Mo2bis 11, 2023 – Mo2bis 20, 2023" |
| `am` medium, equal endpoints | "1 ኤፕሪ 2023" | "ኤፕሪ 1 2023" |
| `en` `Hm`, 10:00 on 15 June to 14:30 on 16 June 2026 | "10:00 – 14:30" | "6/15/2026, 10:00 – 6/16/2026, 14:30" |
| `en` `yMMMMdHm`, the same two days | "June 15, 2026, 10:00 – June 16, 2026, 14:30" | "June 15, 2026 at 10:00 – June 16, 2026 at 14:30" |
| `am` short, 1 to 10 April 2023 | "01/04/2023 – 10/04/2023" | "1/4/2023 – 10/4/2023" |
| `en` `yMMdd`, the same two days | "04/01/2023 – 04/10/2023" | "4/1/2023 – 4/10/2023" |
| `en` `yQQQ`, 15 April to 20 May 2026 | "Q2 2026" | "Q2 2026 – Q2 2026" |
| `en` `E`, 15 to 22 June 2026, both Mondays | "Mon" | "Mon – Mon" |
| `en` `QQQ`, 15 June 2026 to 15 June 2027 | "Q2" | "Q2 – Q2" |
| `en` `MMMd`, 28 December 2026 to 3 January 2027 | "Dec 28 – Jan 3" | "Dec 28, 2026 – Jan 3, 2027" |
| `en` `MMMd`, 15 June 2026 to 15 June 2027 | "Jun 15" | "Jun 15, 2026 – Jun 15, 2027" |
| `en` `d`, 28 June to 3 July 2026 | "28 – 3" | "6/28 – 7/3" |
| `en` `yMMMHm`, 10:00 on 15 June to 14:30 on 16 June 2026 | "Jun 2026, 10:00 – Jun 2026, 14:30" | "Jun 15, 2026, 10:00 – Jun 16, 2026, 14:30" |
| `en` `MMMdHm`, 10:00 on 15 June 2026 to 14:30 on 15 June 2027 | "Jun 15, 10:00 – Jun 15, 14:30" | "Jun 15, 2026, 10:00 – Jun 15, 2027, 14:30" |
| `en` medium, 31 December 1 BC to 1 January AD 1 | "Dec 31, 1 – Jan 1, 1" | "Dec 31, 1 BC – Jan 1, 1 AD" |
| `ja` long, 1 to 10 April 2023 | "2023年4月1日～10日" | "2023/04/01～2023/04/10" |
| `zh` Chinese medium, 14:30:45 to 15:30:45 on 16 June 2026 | "2026年五月初二 14:30:45–2026年五月初二 15:30:45" | "2026年五月2 14:30:45–15:30:45" |
| `en` Chinese `Md`, 16 June 2026 to 12 June 2086 | "5/2" | "5/2 – 5/2" |
| `he` Hebrew medium, 1 to 5 Tamuz 5786 | "א׳–ה׳ בתמוז ה׳תשפ״ו" | "1–5 בתמוז 5786" |

* **The date-time pattern.** TR35 joins a date to a time range with the calendar's `dateTimeFormat`. ICU's `DateIntervalFormat` reads `calendar/gregorian/DateTimePatterns` whatever the calendar, though its single date-time formatter uses the calendar's own.

* **Year symbols.** TR35 finds the closest interval item "as in `availableFormats`", where symbols "representing the same type (year, month, day, etc)" are a distance apart and never a failed match, so the related year `r` and the cyclic year's name `U` of a Chinese or Dangi format take the items CLDR keys by `y`. The item's pattern is then adjusted as TR35 adjusts any: "adjustments should never convert a numeric element in the pattern to an alphabetic element, or the opposite". A numbered year in the item is written as the numbered year asked for, a name stays a name and a number a number: `en`'s medium interval, from "MMM d – d, U", is "Mo5 2 – 6, bing-wu" beside the date "Mo5 2, 2026", and `de`'s, from "y-MM-dd – y-MM-dd", "43-05-02 – 43-05-06" beside "02.05 bing-wu". ICU's interval matcher compares letters one for one, finds no item, and writes both dates in full, "Mo5 2, 2026 – Mo5 6, 2026" and "02.05 bing-wu – 06.05 bing-wu".

* **Years of another cycle.** TR35 writes two dates in full about the fallback pattern where their greatest difference is a field the interval item has no pattern for, and as one date where no field of the pattern differs. It does not say whether the sixty-year cycle of the Chinese and Dangi calendars is such a field, and two dates sixty years apart have one year, month and day. ICU holds the cycle as an era, so they differ in a field no item is keyed by, and it writes both in full in the skeleton's own format: "5/2/2026 – 5/2/2086" for `en`'s `yMd`, 16 June 2026 to 12 June 2086. Localize writes them in full too, TR35 leaving it open. A skeleton that writes no year writes none: two dates sixty years apart are alike in every field of `Md`, and are one date, "5/2", by TR35's step 4, where ICU, to which they differ in an era, writes "5/2 – 5/2".

* **Equal endpoints.** Localize writes the requested standard format, as ECMA-402's `formatRange` and `Localize.Date.to_string/2` do. ICU writes its pattern generator's pattern for the style's skeleton, which prefers an `availableFormats` entry with that skeleton over the standard format.

* **Whole datetimes across days.** TR35 says an interval takes the standard date-time pattern, its example "March 15, 9:00 AM – March 16, 5:00 PM", and Localize joins each datetime's date and time with it (user, 2026-10-02). ICU does so for a date joined to a time range, but formats each whole datetime of a fallback with its single-date pattern, which takes the "at" pattern.

* **Two times of a day no interval item is keyed by.** TR35's algorithm looks the time fields of a skeleton up as an interval item and joins the date, written once, to it; where there is none its last step applies, "format the start and end datetime using the fallback pattern", in which "{0} is replaced by the start datetime, and {1} is replaced by the end datetime". No interval item of CLDR's has seconds, so at a format that writes them, the `:medium`, `:long` and `:full` formats among them, two times of one day are each written whole. ICU writes the date once and the two times around the fallback pattern (user, 2026-10-06: TR35).

* **A time skeleton across days.** TR35's interval algorithm, read as it is written, finds no day difference in a time item and formats both values with the skeleton around the fallback pattern. ICU adds the locale's `yMd` date to a skeleton of time fields alone when the days differ (user, 2026-10-01: not the ICU approach).

* **Numeric widths.** TR35 finds the closest interval item "as in `availableFormats`", whose pattern takes the field lengths of the skeleton requested, so a numeric month and day are padded as the skeleton asks: `am`'s short date is "dd/MM/y" with the skeleton `yMMdd`, and its `yMd` item "d/M/y – d/M/y" is written as the single date is, "01/04/2023". ICU adjusts the width of the year (`yy`) and of a month written as a name (`MMMM`), but writes a numeric month and day at the item's own widths (user, 2026-10-04: TR35).

* **Standard formats.** An interval at a standard format takes the fields of the pattern the single date is written with, at the pattern's widths, where CLDR's `datetimeSkeleton` for the format is not the pattern's (`vi`'s short date "d/M/yy" beside `yMMdd`), as ECMA-402's `formatRange` does with the skeleton ICU makes of a style's pattern (`DateTimePatternGenerator::staticGetSkeleton`). That skeleton is the pattern's letters as they stand, so a month written as a number beside a word is a numeric month to it, and `ja`'s long date "y年M月d日" takes the `yMd` interval. Localize keeps such a month the name CLDR's skeleton gives it (`yMMMd`), whose interval writes "M月" as the date alone does.

* **Fields compared as written.** TR35 formats one date where "there is no difference among any of the fields in the pattern", and Localize compares the value of each field a date interval's format writes, whether or not CLDR has an interval item for it: two dates in one quarter are one quarter, and two Mondays one weekday. ICU ranks calendar fields by size and writes both dates wherever they differ in a field as large as the smallest in the pattern, though the two halves read the same. It makes no pattern for a skeleton of weeks (`yw`, `MMMMW`), for which Localize writes both weeks around the fallback pattern, or one where the days are in the same week.

* **A field the skeleton does not write.** TR35's algorithm writes the fields of the skeleton asked for and no others. Two values alike in every field of the pattern are "a single date"; where the item has no pattern for their greatest difference, "the start and end datetime" are written "using the fallback pattern", each with the skeleton; and for a date and a time "the result will be the same for each `greatestDifference` of a day or longer". So `MMMd` from 28 December to 3 January is "Dec 28 – Jan 3", the same day of two years "Jun 15", `d` from 28 June to 3 July "28 – 3", `yMMMHm` on two days of June "Jun 2026, 10:00 – Jun 2026, 14:30", and two dates of two eras at a format with no era are written without one. ICU adds the day, the month, the year or the era the two differ in, to a skeleton CLDR has an interval item for, and writes a skeleton it has none for as it stands (`Ed`, `QQQ`). Localize adds nothing (user, 2026-10-06: TR35).

* **The date's numbering.** CLDR gives a date pattern the numbering its numeric fields are written in, the `numbers` attribute, and each date a date-time interval writes is the date as `Localize.Date.to_string/2` writes it, in that numbering: `zh`'s Chinese day is "初二" and `he`'s Hebrew date is in Hebrew numerals. ICU's `DateIntervalFormat` works from the format's skeleton, which carries no numbering, and writes digits, though its single date-time formatter writes "2026年五月初二 14:30:45" as Localize does. An interval of two dates at a standard format takes the format's numbering too, since it is made from the format's skeleton, which CLDR gives the same `numbers` attribute, and TR35 has that attribute cover "all of the numeric fields in the date format": `zh`'s Chinese medium interval is "2026年五月初二至初六" and `he`'s Hebrew "א׳–ה׳ בתמוז ה׳תשפ״ו". ICU writes digits there as well. At a skeleton, which states no numbering, both write digits, "2026年五月2至6".

### Relative time

Without `:unit`, `Localize.DateTime.Relative` takes the largest unit of which a whole one lies between two moments, reckoned as ECMA-262 Temporal reckons it: its `DifferenceZonedDateTime` across a change of UTC offset, and its leap-to-common month rules in a lunisolar calendar. ICU4C 78.3's `Calendar::fieldDifference` differs in two places, asserted in `test/localize/datetime/relative_test.exs`. Localize agrees with a step-by-step implementation of the specification on 293,336 date-time pairs around 1,348 offset changes. On 42,035 date pairs in each of ten calendars it agrees with ICU except in the second case, 370 Chinese and 380 Dangi pairs, and in 39 Chinese pairs about the new year of 2027, which ICU starts on 7 February, a day after the Hong Kong Observatory and Calendrical.

| From → to | Localize and Temporal | ICU4C |
|---|---|---|
| New York, 01:50 on 31 October 2026 → 01:10 EST on 1 November | 24 hours 20 minutes | 1 day |
| Chinese, the 2nd of the leap fourth month of 2020 → the 2nd of the fourth month of 2021 | 1 year | 12 months |

* **A repeated hour.** Temporal compares the wall clocks first, and 01:10 is short of 01:50. ICU reaches days and weeks by adding elapsed time, which lands on the first 01:50, but months and years through its fields, which resolve a repeated time at its second occurrence (`UCAL_WALLTIME_LAST`), so its answer turns on the unit.

* **A leap month.** Temporal takes a leap month that the next year lacks as the ordinary month it doubles (its skip-backward rule for the Chinese and Dangi calendars). ICU takes the next new moon, the month after it.

* **A named form.** TR35's `relative` is a name "for the current instance of the field, and one or two past and future instances", an instance being a whole number of the field away, and ECMA-402's `numeric: "auto"` takes the name for that number alone. So only a whole offset is named: 0.9999 days is "in 1 day", the number as it is displayed. ICU4C 78.3 takes an offset within half a hundredth of a whole number for the instance, so 0.996 days is "tomorrow" there and 0.004 days "today" (user, 2026-10-06).

### Durations

`Localize.Duration.new/2` measures two date-times in time zones as the same specification does (user, 2026-10-02): the later is moved to the earlier's time zone, whole days are counted on that wall clock, and the hours, minutes and seconds are the time that passes after them. It agrees with a step-by-step implementation of `DifferenceZonedDateTime` on 322,464 of 324,207 pairs about 36 changes of clocks in twelve time zones, and differs in one case, which it keeps. A sample of the pairs is asserted in `test/localize/duration_zoned_matrix_test.exs`.

| From → to | Localize | The specification's steps |
|---|---|---|
| New York, the second 01:30 on 3 November 2024 → 01:10 on 4 November | 23 hours 40 minutes | 24 hours 40 minutes |

* **From the second occurrence of a repeated time.** Where no whole day lies between the two, the specification finds the earlier moment again from its wall-clock time, which it resolves at its first occurrence, an hour before the moment itself. Localize counts from the earlier moment, so the hours are the time that passes, which is what `DateTime.diff/3` gives.

## Not divergences

These also appear as exclusions in the test suites and should not be read as deliberate differences:

* **Unimplemented features.** `test/localize/locale/locale_display_test.exs` excludes line 47, the `uu` attribute in the `-u-` extension. That is a gap, not a decision.

* **Calendars that are not enabled.** `test/localize/datetime/conformance_test.exs` keeps a `@wrong_format` list of non-Gregorian cases that never run; they are held for whenever the other calendars are switched on. The timezone-resolution and skeleton-matching exclusions that used to sit here are gone: zone name resolution landed, the date/time split path now adjusts the matched pattern's field widths, and `:tz` is a test dependency so a real offset is available.

* **Upstream fixture issues.** The same file's `@invalid_test_results` and the locale-display suite's `@invalid_test_results` mark cases where the conformance data disagrees with CLDR's own data.

Collation carries no exclusions at all: all 210,155 pairs in both CLDR conformance files pass under both strengths.

## Adding a divergence

1. **Read the locale's data.** Open the relevant file in the CLDR JSON, under `$CLDR_PRODUCTION/cldr-*-full/main/<locale>/`, and find the pattern or symbol in question. If Localize is not rendering what is there, it is a defect — stop and fix it.
2. **Check what produced the fixture.** Find the generator in `$CLDR_REPO/tools/` and read the API call. A contract mismatch is fixed in the test, not recorded as a divergence.
3. **Record it with its evidence.** Name what CLDR ships and what ICU renders, so the entry can be re-checked without repeating the investigation.
4. **Assert the count, not just the membership.** An exclusion that silently absorbs new failures stops being a record and becomes a blind spot.
