How to test date format in different locales

Render one fixed instant through new Intl.DateTimeFormat(locale, { dateStyle: 'short' }) for every locale you ship, then assert on formatToParts instead of on the string. The same day prints as 3/8/26 in en-US, 08/03/2026 in en-GB and 8/3/69 in th-TH.

Why check this

Run this on any screen that shows a date to a user in more than one market, and again after a Node, browser or ICU upgrade, because the data behind the formatting ships with the runtime. The failure it prevents is a support ticket from a customer who read 08/03/2026 as August and cancelled the wrong booking.

Pick one instant and keep it for the life of the suite. A date in the first twelve days of a month is the right choice: it is the only range where day and month can be swapped without the output looking wrong, which is where the reports come from.

Prerequisites

// locales.mjs
const d = new Date('2026-03-08T15:45:00Z');
const locales = ['en-US', 'en-GB', 'en-CA', 'de-DE', 'fr-FR', 'ja-JP', 'ko-KR', 'hu-HU',
  'nl-NL', 'pl-PL', 'pt-BR', 'uk-UA', 'ar-EG', 'hi-IN', 'fa-IR', 'th-TH', 'ar-SA'];
console.log('instant', d.toISOString(), 'rendered in UTC');
console.log('locale  short          long                                   calendar / numbering');
for (const loc of locales) {
  const short = new Intl.DateTimeFormat(loc, { timeZone: 'UTC', dateStyle: 'short' });
  const long = new Intl.DateTimeFormat(loc, { timeZone: 'UTC', dateStyle: 'long' });
  const r = short.resolvedOptions();
  console.log(
    loc.padEnd(7),
    short.format(d).padEnd(14),
    long.format(d).padEnd(38),
    `${r.calendar} / ${r.numberingSystem}`
  );
}
// order.mjs
const d = new Date('2026-03-08T15:45:00Z');
for (const loc of ['en-US', 'en-GB', 'ja-JP', 'hu-HU', 'ar-EG']) {
  const parts = new Intl.DateTimeFormat(loc, { timeZone: 'UTC', dateStyle: 'short' }).formatToParts(d);
  const order = parts.filter((p) => p.type !== 'literal').map((p) => p.type).join('-');
  const values = parts.filter((p) => p.type !== 'literal').map((p) => `${p.type}=${p.value}`).join(' ');
  console.log(loc.padEnd(7), order.padEnd(16), values);
}
// calendars.mjs
const d = new Date('2026-03-08T15:45:00Z');
console.log('calendars in this build', Intl.supportedValuesOf('calendar').length);
for (const req of ['ja-JP-u-ca-japanese', 'en-US-u-ca-islamic-umalqura', 'th-TH-u-nu-thai', 'de-AT', 'sr-Latn-RS']) {
  const f = new Intl.DateTimeFormat(req, { timeZone: 'UTC', dateStyle: 'short' });
  const r = f.resolvedOptions();
  console.log(req.padEnd(28), '->', r.locale.padEnd(28), f.format(d));
}
// codepoints.mjs
const d = new Date('2026-03-08T15:45:00Z');
const dump = (label, s) => {
  console.log(label.padEnd(24), 'length', String(s.length).padStart(2),
    '|', [...s].map((c) => 'U+' + c.codePointAt(0).toString(16).toUpperCase().padStart(4, '0')).join(' '));
};
dump('ar-EG short', new Intl.DateTimeFormat('ar-EG', { timeZone: 'UTC', dateStyle: 'short' }).format(d));
dump('he-IL short', new Intl.DateTimeFormat('he-IL', { timeZone: 'UTC', dateStyle: 'short' }).format(d));
dump('en-US short', new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', dateStyle: 'short' }).format(d));
console.log();
console.log(JSON.stringify(new Intl.DateTimeFormat('ar-EG', { timeZone: 'UTC', dateStyle: 'short' }).formatToParts(d)));
// tags.mjs
const d = new Date('2026-03-08T15:45:00Z');
for (const tag of ['en_US', 'english', 'en-ZZ']) {
  try {
    const f = new Intl.DateTimeFormat(tag, { timeZone: 'UTC', dateStyle: 'short' });
    console.log(JSON.stringify(tag).padEnd(11), '-> resolved', f.resolvedOptions().locale.padEnd(7), f.format(d));
  } catch (e) {
    console.log(JSON.stringify(tag).padEnd(11), '->', e.constructor.name + ':', e.message);
  }
}

Steps

  1. Step 1.

    Render one instant in every locale and read the short and the long form side by side.

    node locales.mjs
    
    instant 2026-03-08T15:45:00.000Z rendered in UTC
    locale  short          long                                   calendar / numbering
    en-US   3/8/26         March 8, 2026                          gregory / latn
    en-GB   08/03/2026     8 March 2026                           gregory / latn
    en-CA   2026-03-08     March 8, 2026                          gregory / latn
    de-DE   08.03.26       8. März 2026                           gregory / latn
    fr-FR   08/03/2026     8 mars 2026                            gregory / latn
    ja-JP   2026/03/08     2026年3月8日                              gregory / latn
    ko-KR   26. 3. 8.      2026년 3월 8일                            gregory / latn
    hu-HU   2026. 03. 08.  2026. március 8.                       gregory / latn
    nl-NL   08-03-2026     8 maart 2026                           gregory / latn
    pl-PL   8.03.2026      8 marca 2026                           gregory / latn
    pt-BR   08/03/2026     8 de março de 2026                     gregory / latn
    uk-UA   08.03.26       8 березня 2026 р.                      gregory / latn
    ar-EG   ٨‏/٣‏/٢٠٢٦     ٨ مارس ٢٠٢٦                            gregory / arab
    hi-IN   8/3/26         8 मार्च 2026                           gregory / latn
    fa-IR   ۱۴۰۴/۱۲/۱۷     ۱۷ اسفند ۱۴۰۴                          persian / arabext
    th-TH   8/3/69         8 มีนาคม 2569                          buddhist / latn
    ar-SA   ٨‏/٣‏/٢٠٢٦     ٨ مارس ٢٠٢٦                            gregory / arab

    Three separators, four field orders and three numbering systems in one column. Two lines deserve a second look. fa-IR is not March 2026 at all: the Persian calendar puts that instant at 17 Esfand 1404. th-TH short is 8/3/69, a Buddhist year truncated to two digits, which a reader and a two-digit-year parser both take for 1969.

  2. Step 2.

    Assert on the parts rather than on the rendered string.

    node order.mjs
    
    en-US   month-day-year   month=3 day=8 year=26
    en-GB   day-month-year   day=08 month=03 year=2026
    ja-JP   year-month-day   year=2026 month=03 day=08
    hu-HU   year-month-day   year=2026 month=03 day=08
    ar-EG   day-month-year   day=٨ month=٣ year=٢٠٢٦

    formatToParts gives the field order and the field values without the separators, so a test can state the rule it cares about. An assertion that en-GB is day-month-year survives a CLDR update that changes the separator; an assertion on 08/03/2026 does not.

  3. Step 3.

    Ask for a non-Gregorian calendar and a non-Latin numbering system through the locale tag.

    node calendars.mjs
    
    calendars in this build 18
    ja-JP-u-ca-japanese          -> ja-JP-u-ca-japanese          R8/3/8
    en-US-u-ca-islamic-umalqura  -> en-US-u-ca-islamic-umalqura  9/19/1447 AH
    th-TH-u-nu-thai              -> th-TH-u-nu-thai              ๘/๓/๖๙
    de-AT                        -> de-AT                        08.03.26
    sr-Latn-RS                   -> sr-Latn-RS                   8. 3. 2026.

    R8 is Reiwa year 8, 1447 AH is the same day in the Umm al-Qura calendar, and ๘/๓/๖๙ is Thai digits. Any field that validates a year against a four-digit range, or a day against [0-9], rejects a correctly formatted date here.

  4. Step 4.

    Count the characters in a right-to-left date before writing an assertion against it.

    node codepoints.mjs
    
    ar-EG short              length 10 | U+0668 U+200F U+002F U+0663 U+200F U+002F U+0662 U+0660 U+0662 U+0666
    he-IL short              length  8 | U+0038 U+002E U+0033 U+002E U+0032 U+0030 U+0032 U+0036
    en-US short              length  6 | U+0033 U+002F U+0038 U+002F U+0032 U+0036
    
    [{"type":"day","value":"٨"},{"type":"literal","value":"‏/"},{"type":"month","value":"٣"},{"type":"literal","value":"‏/"},{"type":"year","value":"٢٠٢٦"}]

    The Arabic date shows eight glyphs and holds ten code units. Each separator is U+200F RIGHT-TO-LEFT MARK followed by a slash, and formatToParts reports the pair as one literal. A comparison against a hand-typed ٨/٣/٢٠٢٦ fails, and so does a column width computed from .length. Hebrew, by contrast, comes back as plain digits with no marks at all.

  5. Step 5.

    Send three malformed or unknown locale tags and watch three different outcomes.

    node tags.mjs
    
    "en_US"     -> RangeError: Incorrect locale information provided
    "english"   -> resolved uk-UA   08.03.26
    "en-ZZ"     -> resolved en      3/8/26

    en_US with an underscore, the shape that arrives from POSIX and Java systems, throws. english is structurally valid, matches nothing, and silently resolves to the machine locale, which is uk-UA here and would be en-US on the build agent. en-ZZ keeps the language and drops the unknown region.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | 3/8/26 and 08/03/2026 for one instant | Month-first and day-first, the ambiguity that causes the tickets | Show a long or month-name format wherever the date carries money or a deadline | | A two-digit year such as 69 or 26 | dateStyle: 'short' truncates the year in most locales | Use an explicit year: 'numeric' when the value is stored or re-parsed | | calendar other than gregory | The user's era and year numbering differ, correctly | Never validate the year as a four-digit Gregorian number | | numbering other than latn | Digits are Arabic-Indic, Persian or Thai | Parse back through Intl, not with parseInt on the display string | | A string longer than it looks | Bidi control marks inside the value | Compare parts, not strings, and measure width in graphemes | | A locale that resolves to something else | The tag matched nothing and fell back | Log resolvedOptions().locale next to every rendered date in the test |

Common mistakes

Sign: A Thai date shows the year 69 and the bug is filed as a truncation defect.Cause: th-TH resolves to the buddhist calendar, where 2026 is 2569, and dateStyle short keeps the last two digits. The value is correct. A stored or re-parsed short year is the real defect, because 69 reads as 1969 to any two-digit-year rule.
Sign: An Arabic date matches by eye and fails a string assertion.Cause: ar-EG separators are U+200F RIGHT-TO-LEFT MARK plus a slash, so the value is ten code units where eight glyphs are visible. The mark is invisible in a diff and in a bug report screenshot. Assert on formatToParts values instead.
Sign: The same test gives different dates on a laptop and on the build agent.Cause: An unmatched but well-formed tag such as english falls back to the machine default locale rather than throwing, so the output follows whoever ran it. Read resolvedOptions().locale in the test and fail when it is not the tag you asked for.
Sign: The test expects 03/08/2026 and the application renders 3/8/26.Cause: dateStyle: 'short' and an explicit year, month and day option set are different requests. The first follows the locale pattern, the second forces padding and a four-digit year. Decide which one the product uses and use the same one in the fixture.

What to check next

FAQ

How to check date format quickly?

Format one fixed instant in every shipped locale and print the results in one column, as step 1 does. Differences that are invisible one locale at a time are obvious when the rows sit together.

How to test a date field as a QA?

Cover four cases: a day in the first twelve of a month, where day and month can swap unnoticed; a locale with a non-Gregorian calendar; a right-to-left locale; and an unknown tag. Steps 1, 3, 4 and 5 are those four.

Should a test assert on the formatted string?

Only when the product promises that exact string. CLDR changes separators and patterns between releases, so a string assertion turns a runtime upgrade into a wall of red. Assert on formatToParts field order and values.

Why does an unknown locale not throw?

The specification separates syntax from availability. en_US is bad syntax and throws a RangeError. english is valid syntax with no data behind it, so resolution falls back through the default locale list instead.

Does the browser format dates the same way as Node?

Only when both carry the same ICU and CLDR version. This output came from Node 22.23.2 with ICU 78.2 and CLDR 48. Compare the two before treating a difference between a unit test and a screen as a defect.

Verified

Verified by Maks Vernynode 22.23.2ICU 78.2CLDR 48.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

basic5 minpublished updated Maks Verny