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

Jakub Pomykała
Jakub Pomykała
Last updated: April 10, 202611 min read
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.

FeatureJava 21 (LTS)Java 25 (LTS)Java 26 (Latest)
Locale creationLocale.of() preferredLocale.of() preferredLocale.of() preferred
Deprecated new Locale() constructorsDeprecated (warnings)Deprecated (louder warnings)Deprecated (removal planned)
Unicode version15.016.017.0
CLDR version424548
Bulgaria Euro supportNoYes (2026 amendment)Yes
Default locale debugging-XshowSettings:locale-XshowSettings:localeEnhanced Metric APIs
java.util.Date formattingSupported (legacy)Supported (legacy)Supported (legacy)
java.time.DateTimeFormatterRecommendedRecommendedRecommended
Properties file encodingUTF-8 (since Java 15)UTF-8UTF-8
HTTP client for i18n CDNHTTP/1.1, HTTP/2HTTP/1.1, HTTP/2HTTP/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:

FormatStyleExample (en-US)
SHORT4/10/26, 2:30 PM
MEDIUMApr 10, 2026, 2:30:00 PM
LONGApril 10, 2026 at 2:30:00 PM CEST
FULLThursday, 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:

CharacterCode PointEffect
Zero-width spaceU+200BBreaks search and comparison
Non-breaking spaceU+00A0Not matched by \s in regex
Left-to-right markU+200ECan cause rendering issues in wrong context
Byte order markU+FEFFCan 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:

  1. Replace all new Locale(...) constructor calls with Locale.of(...).
  2. Replace java.util.Date + DateFormat usage with java.time.ZonedDateTime + DateTimeFormatter.
  3. Ensure .properties files are saved as UTF-8 (should already be the case if you are on Java 15+).
  4. Run regression tests on Bulgarian currency formatting if you have Bulgarian users.
  5. Run collation tests for East Asian locales if you sort or compare strings in those languages.
  6. 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

Jakub Pomykała
Jakub Pomykała
Founder of SimpleLocalize

Get started with SimpleLocalize

  • All-in-one localization platform
  • Web-based translation editor for your team
  • Auto-translation, QA-checks, AI and more
  • See how easily you can start localizing your product.
  • Powerful API, hosting, integrations and developer tools
  • Unmatched customer support
Start for free
No credit card required5-minute setup
"The product
and support
are fantastic."
Laars Buur|CTO
"The support is
blazing fast,
thank you Jakub!"
Stefan|Developer
"Interface that
makes any dev
feel at home!"
Dario De Cianni|CTO
"Excellent app,
saves my time
and money"
Dmitry Melnik|Developer