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
- Node 22 with full ICU.
node -p "Intl.supportedValuesOf('calendar').length"printed18here. A small-ICU build formats everything asen-USand the whole check passes for the wrong reason. - The locale tags your product ships, in the form the client sends them.
- The Intl.DateTimeFormat reference for option names, and BCP 47 for tag syntax.
- Five scripts.
// 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
- Step 1.
Render one instant in every locale and read the short and the long form side by side.
node locales.mjsinstant 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 / arabThree separators, four field orders and three numbering systems in one column. Two lines deserve a second look.
fa-IRis not March 2026 at all: the Persian calendar puts that instant at 17 Esfand 1404.th-THshort is8/3/69, a Buddhist year truncated to two digits, which a reader and a two-digit-year parser both take for 1969. - Step 2.
Assert on the parts rather than on the rendered string.
node order.mjsen-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=٢٠٢٦formatToPartsgives the field order and the field values without the separators, so a test can state the rule it cares about. An assertion thaten-GBisday-month-yearsurvives a CLDR update that changes the separator; an assertion on08/03/2026does not. - Step 3.
Ask for a non-Gregorian calendar and a non-Latin numbering system through the locale tag.
node calendars.mjscalendars 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.R8is Reiwa year 8,1447 AHis 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. - Step 4.
Count the characters in a right-to-left date before writing an assertion against it.
node codepoints.mjsar-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 MARKfollowed by a slash, andformatToPartsreports 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. - 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/26en_USwith an underscore, the shape that arrives from POSIX and Java systems, throws.englishis structurally valid, matches nothing, and silently resolves to the machine locale, which isuk-UAhere and would been-USon the build agent.en-ZZkeeps 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
What to check next
- How to check the thousand separator and decimal separator: the same locale data, applied to quantities.
- How to test currency formatting: where symbol position and digit count move with the locale.
- How to check the browser locale: which tag the client sends before any of this runs.
- How to test timezone handling: the instant behind the rendering, and why it can be a day out.
- How to test pluralization: the other
Intlrule set that breaks on languages with more than two forms. - Localization testing checklist: the full pass this check belongs to.
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.
Related on this site
basic5 minpublished updated Maks Verny