Java Internationalization: Java 21, Java 25 LTS, and Java 26

Java internationalization (i18n) spans a lot of ground: locale handling, number and date formatting, resource bundles, Unicode support, and pluralization. The core APIs have been stable for years, but each major release brings meaningful changes to Unicode data, collation rules, and currency support that affect real applications.
This guide covers Java i18n with an explicit focus on version differences between Java 21 (LTS), Java 25 (LTS), and Java 26 (latest). If you are maintaining an older codebase or planning a migration, the comparison tables and breaking-change sections below give you a clear picture of what changes and what stays the same.
If you are newer to i18n concepts, start with our complete technical guide to internationalization and software localization before diving into the Java-specific implementation details here.
Compatibility at a glance
This table is designed to give you a quick snapshot before diving into details. If you are evaluating an upgrade or auditing your dependencies, start here.
| Feature | Java 21 (LTS) | Java 25 (LTS) | Java 26 (Latest) |
|---|---|---|---|
Locale creation | Locale.of() preferred | Locale.of() preferred | Locale.of() preferred |
Deprecated new Locale() constructors | Deprecated (warnings) | Deprecated (louder warnings) | Deprecated (removal planned) |
| Unicode version | 15.0 | 16.0 | 17.0 |
| CLDR version | 42 | 45 | 48 |
| Bulgaria Euro support | No | Yes (2026 amendment) | Yes |
| Default locale debugging | -XshowSettings:locale | -XshowSettings:locale | Enhanced Metric APIs |
java.util.Date formatting | Supported (legacy) | Supported (legacy) | Supported (legacy) |
java.time.DateTimeFormatter | Recommended | Recommended | Recommended |
| Properties file encoding | UTF-8 (since Java 15) | UTF-8 | UTF-8 |
| HTTP client for i18n CDN | HTTP/1.1, HTTP/2 | HTTP/1.1, HTTP/2 | HTTP/3 (JEP 517) |
Breaking changes: Java 21 to Java 25
Before updating, check these specific changes that can affect localized output:
Collation changes (CLDR 42 to CLDR 45)
CLDR 45 (shipped with Java 25) updated sorting rules for several locales. The most visible change for most teams is in East Asian languages, where some date/time separators changed. If your application sorts or compares strings in Chinese, Japanese, or Korean, run regression tests against your current output.
Bulgaria Euro transition
Java 25 includes the ISO 4217 update for Bulgaria adopting the Euro in 2026. If you are formatting Bulgarian lev (BGN), your currency output will change when running on Java 25+. Test your currency formatting if you have Bulgarian users:
// Java 21
NumberFormat.getCurrencyInstance(Locale.of("bg", "BG")).format(100);
// 100,00 лв.
// Java 25+ (after Euro adoption date)
NumberFormat.getCurrencyInstance(Locale.of("bg", "BG")).format(100);
// 100,00 €
Deprecated Locale constructors
Constructors deprecated in Java 19 emit louder warnings in Java 25 and are candidates for removal in a future release. If you are still using new Locale("en", "US"), migrate to Locale.of("en", "US") now.
Swedish collation
This was introduced in Java 21 but is worth checking if you migrated from an older version. The Swedish locale now distinguishes between v and w in sort order. Old behavior is available via Locale.forLanguageTag("sv-co-u-trad").
Locale class
Since Java 19, all constructors of the Locale class have been deprecated. Use Locale.of() static methods or Locale.Builder instead.
// Preferred: static factory methods
Locale.of(language);
Locale.of(language, country);
Locale.of(language, country, variant);
// Preferred: builder pattern
new Locale.Builder()
.setLanguage("en")
.setRegion("US")
.build();
// Deprecated: do not use in new code
new Locale("en");
new Locale("en", "US");
Java 25 note: Flexible constructor bodies (JEP 513) allow you to perform locale validation before calling super(). This matters when you are subclassing or wrapping locale resolution logic, previously a pain point because you could not validate arguments before the parent constructor ran.
Debugging the active locale
The -XshowSettings:locale flag introduced in Java 21 is the fastest way to verify which locale your JVM is using. This is particularly useful when diagnosing locale mismatches between development and production environments.
java -XshowSettings:locale -version
Output includes the default locale, the default display locale, and the default format locale. Useful when your CI environment uses a different system locale than your development machine.
Date and time formatting
Use java.time and DateTimeFormatter. The older java.util.Date and DateFormat classes are still available, but every new code should use the java.time package introduced in Java 8. It is thread-safe, immutable, and expressive.
ZonedDateTime dt = ZonedDateTime.of(2026, 4, 10, 14, 30, 0, 0, ZoneId.of("Europe/Warsaw"));
DateTimeFormatter formatter = DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.FULL)
.withLocale(Locale.of("pl", "PL"));
formatter.format(dt);
// czwartek, 10 kwietnia 2026 14:30:00 czas środkowoeuropejski letni
The FormatStyle enum mirrors the legacy DateFormat constants:
FormatStyle | Example (en-US) |
|---|---|
SHORT | 4/10/26, 2:30 PM |
MEDIUM | Apr 10, 2026, 2:30:00 PM |
LONG | April 10, 2026 at 2:30:00 PM CEST |
FULL | Thursday, April 10, 2026 at 2:30:00 PM Central European Summer Time |
Always store timestamps in UTC. Convert to the user's time zone only at display time.
// Store
Instant utc = Instant.now();
// Display
ZonedDateTime userTime = utc.atZone(ZoneId.of("Asia/Tokyo"));
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withLocale(Locale.JAPAN)
.format(userTime);
// 2026/04/10 23:30:00
Date format conventions differ significantly by region:
US: MM/DD/YYYY → 04/10/2026
EU: DD.MM.YYYY → 10.04.2026
ISO: YYYY-MM-DD → 2026-04-10 (use this for data storage)
Number formatting
Currencies
Currency formatting adapts the symbol, its placement, and the decimal/thousands separators to each locale automatically:
Locale localePoland = Locale.of("pl", "PL");
Locale localeFrance = Locale.of("fr", "FR");
Locale localeUS = Locale.US;
NumberFormat.getCurrencyInstance(localePoland).format(1234.56);
// 1 234,56 zł
NumberFormat.getCurrencyInstance(localeFrance).format(1234.56);
// 1 234,56 €
NumberFormat.getCurrencyInstance(localeUS).format(1234.56);
// $1,234.56
Decimal separators, thousands separators, and currency symbol placement vary by locale. Do not build these strings manually.
General numbers
Plain number formatting applies locale-specific grouping and decimal separators:
NumberFormat.getNumberInstance(localeUS).format(1234567.89);
// 1,234,567.89
NumberFormat.getNumberInstance(localeFrance).format(1234567.89);
// 1 234 567,89
NumberFormat.getNumberInstance(localePoland).format(1234567.89);
// 1 234 567,89
Compact numbers
Compact number formatting abbreviates large numbers using locale-aware suffixes like "M", "mln", or "mille":
NumberFormat.getCompactNumberInstance(localeUS, NumberFormat.Style.SHORT).format(1_213_700);
// 1M
NumberFormat.getCompactNumberInstance(localeFrance, NumberFormat.Style.SHORT).format(1_213_700);
// 1 M
NumberFormat.getCompactNumberInstance(localePoland, NumberFormat.Style.SHORT).format(1_213_700);
// 1 mln
NumberFormat.getCompactNumberInstance(localeUS, NumberFormat.Style.LONG).format(12_137);
// 12 thousand
NumberFormat.getCompactNumberInstance(localeFrance, NumberFormat.Style.LONG).format(12_137);
// 12 mille
NumberFormat.getCompactNumberInstance(localePoland, NumberFormat.Style.LONG).format(12_137);
// 12 tysięcy
Percentages
Percentage formatting multiplies the value by 100 and appends the percent sign in the locale-appropriate position:
NumberFormat.getPercentInstance(localeUS).format(0.75);
// 75%
NumberFormat.getPercentInstance(localeFrance).format(0.75);
// 75 %
NumberFormat.getPercentInstance(Locale.of("ar", "SA")).format(0.75);
// ٪٧٥
Currency symbols and display names
You can retrieve the localized display name and symbol of any currency independently from formatting a number:
Currency currency = DecimalFormatSymbols.getInstance(localePoland).getCurrency();
currency.getDisplayName(localePoland);
// złoty polski
currency.getDisplayName(localeFrance);
// zloty polonais
currency.getDisplayName(localeUS);
// Polish zloty
currency.getSymbol(localePoland);
// zł
currency.getSymbol(localeUS);
// PLN
The display name and symbol depend on the locale used to view the currency, not the currency itself.
Resource bundles and properties files
ResourceBundle loads locale-specific key-value pairs from .properties files.
Encoding note: Since Java 15, .properties files default to UTF-8. You no longer need to escape non-ASCII characters with \uXXXX sequences. This means Polish, Chinese, Arabic, and any other script can be written directly in the file.
# messages_pl_PL.properties (UTF-8, no escaping needed)
footerText=© 2026 SimpleLocalize. Wszelkie prawa zastrzeżone.
linkText=Utwórz konto SimpleLocalize
message=Dziękujemy za wypróbowanie naszego demo SimpleLocalize dla Javy!
greeting=Hej {0}!
fileCount={0,choice,0#brak plików|1#jeden plik|1<{0} pliki}
Loading and using the bundle:
ResourceBundle bundle = ResourceBundle.getBundle("messages", Locale.of("pl", "PL"));
bundle.getString("linkText");
// Utwórz konto SimpleLocalize
// Simple placeholder substitution
String template = bundle.getString("greeting");
MessageFormat.format(template, "Jakub");
// Hej Jakub!
ResourceBundle supports inheritance: if a key is missing in messages_pl_PL.properties, Java looks in messages_pl.properties, then messages.properties. This is the fallback chain built into the API.
Pluralization with MessageFormat
Most languages have more than two plural forms. English has singular and plural. Polish has four. Arabic has six. MessageFormat supports the ICU-style choice format for simple cases, but for full pluralization support across languages, use a library like unicode-org/icu4j or manage plural keys through a translation platform like SimpleLocalize that stores plural variants per key.
// English pluralization with MessageFormat choice format
String pattern = "{0,choice,0#no files|1#one file|1<{0} files}";
MessageFormat.format(pattern, 0); // no files
MessageFormat.format(pattern, 1); // one file
MessageFormat.format(pattern, 42); // 42 files
For a deeper treatment of plural rules across languages, see our guide on how to handle pluralization across languages.
Locale-aware string operations
String operations in Java are locale-sensitive in ways that are easy to overlook.
// Wrong: uses the default locale, which varies by environment
"TITLE".toLowerCase();
// Correct: explicit locale
"TITLE".toLowerCase(Locale.ENGLISH);
// Turkish example: critical difference
"TITLE".toLowerCase(Locale.ENGLISH); // title
"TITLE".toLowerCase(Locale.forLanguageTag("tr")); // tıtle ← dotless i
The Turkish lowercase I → ı (dotless i) is the classic example. If your application runs on a system with a Turkish default locale and you call toLowerCase() without an explicit locale, string comparisons involving i and I will produce unexpected results.
Always pass an explicit Locale to case conversion methods.
Locale-aware sorting
Alphabetical sort order is locale-dependent. In Swedish, ä sorts after z. In German, ä sorts as a variant of a. JavaScript's equivalent issue is covered in our number formatting in JavaScript guide.
List<String> names = Arrays.asList("Müller", "Mueller", "Mustermann");
// Wrong: uses Unicode code point order
Collections.sort(names);
// Correct: uses locale-aware collation
Collator collator = Collator.getInstance(Locale.GERMAN);
names.sort(collator);
Unicode and encoding
UTF-8 everywhere. Every layer of your stack — database, API, file storage, frontend — should use UTF-8. Mismatches at integration boundaries produce garbled characters: é where é should appear is a classic sign that UTF-8 bytes are being interpreted as Latin-1.
String normalization
Unicode allows the same character to be represented in multiple ways. The letter é can be a single precomposed character (U+00E9) or e followed by a combining acute accent (U+0065 U+0301). These are visually identical but byte-for-byte different, which can cause bugs in string comparison, search, and deduplication.
Normalize all incoming text to NFC at your API boundary:
import java.text.Normalizer;
String normalized = Normalizer.normalize(input, Normalizer.Form.NFC);
Stripping diacritics (for slug generation, search normalization):
Normalizer
.normalize("Tĥïŝ ĩš â fůňķŷ Šťŕĭńġ", Normalizer.Form.NFD)
.replaceAll("[^\\p{ASCII}]", "");
// This is a funky String
Half-width to full-width normalization (Japanese and Chinese content):
Normalizer.normalize("Hello, world!", Normalizer.Form.NFKC);
// Hello, world!
Invisible character pitfalls
These characters appear in copy-pasted content and translation files and cause subtle bugs:
| Character | Code Point | Effect |
|---|---|---|
| Zero-width space | U+200B | Breaks search and comparison |
| Non-breaking space | U+00A0 | Not matched by \s in regex |
| Left-to-right mark | U+200E | Can cause rendering issues in wrong context |
| Byte order mark | U+FEFF | Can cause parser failures at file start |
Strip or validate unexpected control characters during translation import. These are invisible in editors, which makes them difficult to diagnose.
Migrating from Java 21 to Java 25 LTS
The ResourceBundle and MessageFormat APIs are unchanged. The migration cost is primarily in testing localized output, not in rewriting code.
Migration checklist:
- Replace all
new Locale(...)constructor calls withLocale.of(...). - Replace
java.util.Date+DateFormatusage withjava.time.ZonedDateTime+DateTimeFormatter. - Ensure
.propertiesfiles are saved as UTF-8 (should already be the case if you are on Java 15+). - Run regression tests on Bulgarian currency formatting if you have Bulgarian users.
- Run collation tests for East Asian locales if you sort or compare strings in those languages.
- Verify the Swedish sort order change if your application sorts Swedish content.
The underlying behavior of ResourceBundle, NumberFormat, and Collator does not change between 21 and 25. What changes is the underlying Unicode data (CLDR 42 to CLDR 45), which can shift formatted output for specific locales.
Performance tips
Cache formatter instances
Creating NumberFormat and DateFormat instances is expensive. Cache them — but note that DateFormat and legacy NumberFormat instances are not thread-safe:
public class FormatterCache {
private static final Map<String, NumberFormat> numberFormats = new ConcurrentHashMap<>();
public static NumberFormat getCurrencyFormatter(Locale locale) {
return numberFormats.computeIfAbsent(
"currency_" + locale,
k -> NumberFormat.getCurrencyInstance(locale)
);
}
}
DateTimeFormatter (from java.time) is thread-safe and can be safely stored as a static field.
// Thread-safe: DateTimeFormatter is immutable
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
.withLocale(Locale.ENGLISH);
Handle missing translations defensively
Wrap ResourceBundle access to return the key itself instead of throwing when a translation is missing:
public class SafeResourceBundle {
private final ResourceBundle bundle;
public SafeResourceBundle(String baseName, Locale locale) {
this.bundle = ResourceBundle.getBundle(baseName, locale);
}
public String getString(String key) {
try {
return bundle.getString(key);
} catch (MissingResourceException e) {
// Log for monitoring, return key as fallback
logger.warn("Missing translation key: {}", key);
return key;
}
}
}
In production, missing keys should fall back silently and log to your error tracking system. In development, you want loud failures so missing keys are caught early. A common pattern is to configure this behavior via an environment variable.
Managing Java translations at scale
The ResourceBundle API handles loading. The harder problem is managing dozens of .properties files across multiple languages and keeping them in sync with your codebase.
Teams that grow beyond a handful of languages typically move .properties files into a translation management platform and sync them via CLI or CI/CD. SimpleLocalize supports .properties files natively, letting you push new keys on commit and pull translations back into your build:
# Push new keys extracted from source code
simplelocalize upload --apiKey $API_KEY
# Pull translations for all supported locales
simplelocalize download --apiKey $API_KEY
This approach decouples translation updates from code deployments. Translators can publish changes independently, and the CI pipeline validates completeness before each release.
For a complete overview of CI/CD integration patterns for localization, see the localization workflow for developers.
References
- Java 25: Locale class
- Java 25: NumberFormat class
- Java 25: DecimalFormatSymbols class
- Java 25: DateTimeFormatter
- Java 25: ResourceBundle class
- Java 25: Collator class
- JEP 513: Flexible Constructor Bodies
- CLDR 45 release notes
- Spring Boot 3.5: Internationalization with messages.properties
- Complete guide to pluralization across languages
- ICU message format guide




