Skip to content

Java 21 Localization and Resource Bundles

Step 19 of 20

Java 21 Localization: Locales, Resource Bundles, and Formatting

Introduction

Java 21 localization combines Locale, ResourceBundle, and locale-aware message, number, currency, and date formatting. These skills support the Implementing Localization topic for Oracle Java SE 21 Developer (1Z0-830). You will trace resource fallback and parse and format customer-facing values with explicit locales.

The running example is a small e-commerce site for a company that sells in the United States, France and Japan. It needs translated labels, prices in the right currency, dates in the local style, and messages such as "You have 3 items in your cart".

Learning objectives

After this lesson you will be able to:

  • Create and use Locale objects, and explain the default locale and the categories of default locales.
  • Create ResourceBundles from properties files and from ListResourceBundle classes, and predict the lookup order, including fallback and parent chains.
  • Format and parse numbers, currencies and percentages with NumberFormat, and compact numbers with CompactNumberFormat.
  • Format and parse dates and times with DateTimeFormatter in a locale.
  • Build messages with MessageFormat, including choice and number arguments.
  • Avoid common mistakes with encodings, locale-sensitive String methods and the default locale.

1. The Locale class

A Locale identifies a language, optionally a country or region, optionally a script and a variant. Examples: en (English), en_US, fr_FR, ja_JP.

Java
Locale us  = Locale.US;                         // constant: en_US
Locale fr  = Locale.FRENCH;                     // language only: fr
Locale ca  = Locale.CANADA_FRENCH;              // fr_CA
Locale de  = Locale.of("de", "CH");             // Java 19+ factory
Locale old = new Locale("it", "IT");            // constructors are deprecated since Java 19
Locale tag = Locale.forLanguageTag("pt-BR");    // BCP 47 tag with a dash
Locale built = new Locale.Builder().setLanguage("es").setRegion("MX").build();

us.getLanguage();             // en
us.getCountry();              // US
us.getDisplayCountry(Locale.US); // United States (explicit display locale)
us.getDisplayName(Locale.FRANCE); // anglais (États-Unis)
us.toString();                // en_US
us.toLanguageTag();           // en-US

Key locale rules:

  • Language codes use lowercase (en); regions use uppercase two-letter ISO 3166 codes (US) or three-digit UN M.49 codes. Scripts use four-letter title case (Latn). The Locale factories normalize case of the language to lowercase and the country to uppercase, but they do not verify that the code exists. Locale.of("xx", "YY") is created without an error. Locale.Builder checks syntactic well-formedness, not whether a code is registered.
  • toString() uses an underscore (fr_FR), toLanguageTag() and forLanguageTag() use a dash.
  • The default locale comes from the operating system or JVM options. Locale.getDefault() returns it. Locale.setDefault(Locale) changes it for the whole JVM. There are also two category defaults, Locale.Category.DISPLAY and Locale.Category.FORMAT, which can be set separately with Locale.setDefault(Locale.Category, Locale).
  • Locale.getAvailableLocales() returns the locales supported by the runtime.
  • Many methods use the default locale if you do not pass one. For test reproducibility, always pass a locale explicitly.
  • String.toUpperCase() and toLowerCase() use the default locale. In a Turkish locale "title".toUpperCase() gives TİTLE (with a dotted capital I). Use toUpperCase(Locale.ROOT) or Locale.ENGLISH for technical strings.

2. Resource bundles

A resource bundle is a set of translated key/value pairs. Java offers two kinds.

Properties bundles

Create files named baseName.properties (default) and baseName_xx.properties or baseName_xx_YY.properties in a package of the class path.

Properties
# Messages.properties (default)
greeting=Hello
cart.empty=Your cart is empty

# Messages_fr.properties
greeting=Bonjour
cart.empty=Votre panier est vide

# Messages_fr_CA.properties
greeting=Allo
Java
ResourceBundle rb = ResourceBundle.getBundle("Messages", Locale.CANADA_FRENCH);
rb.getString("greeting");        // Allo
rb.getString("cart.empty");      // found in the parent: Votre panier est vide
rb.getObject("greeting");        // Object
rb.containsKey("greeting");      // true
rb.keySet();                     // all keys, including those of the parents
rb.getString("unknown");         // MissingResourceException (unchecked)

Continue with Java file I/O and explicit text encodings.

In Java 21, PropertyResourceBundle(InputStream) tries UTF-8 and, on an encoding error, ISO-8859-1 unless its encoding system property forces one encoding. Unicode escapes such as \u00E9 also work. Do not generalize this to Properties.load(InputStream), which still uses ISO-8859-1; a reader-based overload uses the reader's chosen decoding.

If the bundle is in a package, the base name includes it: ResourceBundle.getBundle("com.shop.i18n.Messages"). If no bundle exists at all, getBundle throws MissingResourceException (unchecked).

ListResourceBundle

A class extending ListResourceBundle returns a two-dimensional array of pairs. It is the way to store values that are not strings (such as numbers or objects).

Java
public class Prices_fr extends ListResourceBundle {
    @Override
    protected Object[][] getContents() {
        return new Object[][] {
            { "shipping", 4.90 },
            { "freeFrom", 50 }
        };
    }
}

getObject("shipping") returns a Double; getString("shipping") throws ClassCastException because the value is not a String.

The lookup order

For the ordinary class-path call ResourceBundle.getBundle("Messages", Locale.of("fr", "CA")) with default locale en_US, separate bundle selection from key inheritance:

  1. Inspect requested-locale candidates Messages_fr_CA, Messages_fr, and the base Messages. A localized French match takes priority. A base-only match is held as a last resort.
  2. If no localized French match exists, try the default-locale candidates Messages_en_US, Messages_en, and the base. A localized English match can be chosen before the held base bundle.
  3. If neither locale has a localized match, use the base bundle; if none exists, throw MissingResourceException.

Once Messages_fr_CA is selected, missing keys go through its parent chain, Messages_fr then Messages. They do not search English default-locale bundles. At the same candidate level, a valid ListResourceBundle class takes precedence over a properties file. This example omits script and variant candidates, which add further levels.

Example. If no French bundle exists but Messages_en and Messages do, a French request can select Messages_en. If Messages_fr exists but lacks a key, that key falls back to the base, not to Messages_en.

3. Formatting numbers

java.text.NumberFormat is the abstract factory for locale-aware number formats:

Java
double price = 1234.5;
NumberFormat plain = NumberFormat.getInstance(Locale.US);            // 1,234.5
NumberFormat money = NumberFormat.getCurrencyInstance(Locale.US);    // $1,234.50
NumberFormat euro  = NumberFormat.getCurrencyInstance(Locale.FRANCE);// 1 234,50 € (non-breaking space)
NumberFormat yen   = NumberFormat.getCurrencyInstance(Locale.JAPAN); // yen symbol; 1234.5 rounds to 1234 with HALF_EVEN
NumberFormat pct   = NumberFormat.getPercentInstance(Locale.US);     // 0.256 -> 26%
NumberFormat integer = NumberFormat.getIntegerInstance(Locale.US);   // rounds: 2.5 -> 2 (HALF_EVEN)

money.format(price);          // "$1,234.50"
pct.format(0.256);            // "26%"

Important points:

  • The currency symbol and its position come from the locale, but the amount is not converted. Formatting 1234.5 with Locale.JAPAN shows yen and does not exchange anything.
  • The JDK's DecimalFormat and CompactNumberFormat defaults use HALF_EVEN; this is not a rounding guarantee for every possible NumberFormat implementation. getIntegerInstance().format(2.5) is 2, and format(3.5) is 4. Change it with setRoundingMode.
  • setMaximumFractionDigits, setMinimumFractionDigits and setGroupingUsed(false) customize the output.
  • NumberFormat.getCurrencyInstance for the same amount prints different digits per currency: Japanese yen has no fraction digits.
  • setCurrency(Currency.getInstance("EUR")) changes currency identity, not exchange value. On DecimalFormat it does not automatically adjust fraction-digit limits; configure those when switching between currencies such as JPY and EUR.
  • NumberFormat and MessageFormat are mutable and not generally thread-safe; use separate instances or appropriate synchronization.

Parsing uses parse(String), which returns a Number and throws the checked ParseException:

Java
NumberFormat fr = NumberFormat.getInstance(Locale.FRANCE);
Number n = fr.parse("1234,5");     // 1234.5 without a grouping separator
NumberFormat us = NumberFormat.getInstance(Locale.US);
us.parse("1,234.56abc");          // 1234.56: parsing stops at the first invalid character
us.parse("abc");                  // ParseException

parse does not require the entire string to be consumed; it reads from the beginning as far as it can and ignores the rest. It throws only if it cannot read anything at the start. French grouping commonly uses a narrow non-breaking space (\u202F) with JDK locale data; an ordinary ASCII space is not a reliable substitute. Number subtypes depend on the formatter and configuration.

For complete-input validation, use a ParsePosition and require both a non-null result and an index equal to the input length:

Java
String input = "1,234.56abc";
ParsePosition pos = new ParsePosition(0);
Number value = us.parse(input, pos);
boolean complete = value != null && pos.getIndex() == input.length(); // false

Compact numbers

CompactNumberFormat (Java 12) abbreviates large numbers:

Java
NumberFormat shortFmt = NumberFormat.getCompactNumberInstance(Locale.US, NumberFormat.Style.SHORT);
NumberFormat longFmt  = NumberFormat.getCompactNumberInstance(Locale.US, NumberFormat.Style.LONG);
shortFmt.format(1_000);       // 1K
shortFmt.format(2_500);       // 2K   (HALF_EVEN, no fraction digits by default)
shortFmt.format(1_500_000);   // 2M
longFmt.format(1_000);        // 1 thousand
longFmt.format(3_000_000);    // 3 million
shortFmt.setMaximumFractionDigits(1);
shortFmt.format(2_500);       // 2.5K

The default maximum number of fraction digits is zero, so 2_500 becomes 2K, not 2.5K. The default rounding is HALF_EVEN, which means 2.5 is rounded to the even neighbor 2 and 1.5 million becomes 2M.

4. Formatting dates and times

Continue with Java date-time types and arithmetic.

Use the java.time API with DateTimeFormatter. The older DateFormat/SimpleDateFormat still exists but is not thread-safe and is secondary.

Java
LocalDate d = LocalDate.of(2026, 10, 9);
LocalDateTime dt = LocalDateTime.of(2026, 10, 9, 14, 30);

DateTimeFormatter shortUs = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT).withLocale(Locale.US);
shortUs.format(d);        // 10/9/26
DateTimeFormatter longFr = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(Locale.FRANCE);
longFr.format(d);         // 9 octobre 2026
DateTimeFormatter fullDe = DateTimeFormatter.ofLocalizedDate(FormatStyle.FULL).withLocale(Locale.GERMANY);
fullDe.format(d);         // Freitag, 9. Oktober 2026

DateTimeFormatter custom = DateTimeFormatter.ofPattern("EEEE d MMMM yyyy", Locale.FRANCE);
custom.format(d);         // vendredi 9 octobre 2026

Rules:

  • ofLocalizedDate accepts only date-based styles; with a LocalTime it throws UnsupportedTemporalTypeException. ofLocalizedTime needs a time. ofLocalizedDateTime needs both. FormatStyle.LONG and FULL for times need a zone, so they fail on a LocalDateTime (DateTimeException).
  • The pattern letters are case-sensitive: MM is month, mm is minutes; yyyy is year-of-era, uuuu is proleptic year, and YYYY is week-based year; dd is day-of-month; HH is the hour 0 to 23, hh is 1 to 12 with a for AM/PM.
  • ofPattern(pattern) without a locale uses the default FORMAT locale. Always pass a locale for names of months and days.
  • Parsing with ofPattern uses ResolverStyle.SMART by default, which can adjust some invalid day-of-month values. For strict date validation, use DateTimeFormatter.ofPattern("dd/MM/uuuu", Locale.ROOT).withResolverStyle(ResolverStyle.STRICT). With that formatter, LocalDate.parse("30/02/2026", formatter) throws the unchecked DateTimeParseException.
  • Localized styles and spacing can vary with JDK locale data (CLDR is the default since Java 9). Treat the displayed localized strings as examples, not universal byte-for-byte contracts. DateTimeFormatter is immutable and thread-safe.

5. Messages with MessageFormat

MessageFormat inserts arguments into a pattern with {index}.

Java
String pattern = "{0} bought {1} items for {2,number,currency} on {3,date,long}.";
MessageFormat mf = new MessageFormat(pattern, Locale.US);
mf.format(new Object[] { "Alice", 3, 59.9, new Date() });
// Alice bought 3 items for $59.90 on a date determined by new Date().
// The final date also depends on the formatter's time zone.

MessageFormat.format("{0} + {1} = {2}", 1, 2, 3);        // "1 + 2 = 3"
MessageFormat.format("It''s {0}", "ok");                 // "It's ok" (two apostrophes)
MessageFormat.format("It's {0}", "ok");                  // "Its {0}" - single quote starts a quoted section

The single quote is the escape character. A single apostrophe quotes the following text so that {0} is not replaced; to print an apostrophe, double it. Arguments are numbered from zero; an index with no argument prints as {n}. A choice format handles plurals:

Java
String p = "You have {0,choice,0#no items|1#one item|1<{0,number,integer} items}.";
MessageFormat.format(p, 0);   // You have no items.
MessageFormat.format(p, 1);   // You have one item.
MessageFormat.format(p, 3);   // You have 3 items.

In practice, the pattern is stored in a resource bundle: cart.summary=You have {0} items. and then combined with new MessageFormat(rb.getString("cart.summary"), customerLocale).format(new Object[] { count }), so number formatting uses the customer locale rather than the JVM default. Also, String.format(Locale, fmt, args) and printf(Locale, ...) accept a locale for %,.2f.

Worked example: which bundle is selected?

Statement. The class path holds Shop.properties (title=Shop, bye=Goodbye), Shop_fr.properties (title=Boutique), Shop_en.properties (title=Store) and Shop_fr_CA.properties (bye=Au revoir). The default locale is en_US. What does this code print?

Java
ResourceBundle rb = ResourceBundle.getBundle("Shop", Locale.of("fr", "CA"));
System.out.println(rb.getString("title") + " / " + rb.getString("bye"));
ResourceBundle rb2 = ResourceBundle.getBundle("Shop", Locale.GERMANY);
System.out.println(rb2.getString("title"));

Step 1. For fr_CA the first candidate Shop_fr_CA exists, so it is selected. Its parent chain is Shop_fr then Shop.

Step 2. title is not defined in Shop_fr_CA, so the parent Shop_fr supplies Boutique. bye is in Shop_fr_CA, so Au revoir. The first line is Boutique / Au revoir.

Step 3. For de_DE, candidates Shop_de_DE and Shop_de do not exist. The default locale en_US is then tried: Shop_en_US does not exist, but Shop_en does. It is selected before the base bundle, and title is Store.

Solution. The output is Boutique / Au revoir and then Store.

Why the other choices are wrong. Printing Shop for the second line assumes the base bundle is used immediately when the requested locale is missing, ignoring the default locale step. Printing Au revoir / Au revoir assumes keys cannot be inherited. A MissingResourceException would only occur if no bundle at all matched.

Second worked example: formatting prices and compact numbers

Statement. Given the default locale en_US, what is printed?

Java
double amount = 0.256;
System.out.println(NumberFormat.getPercentInstance(Locale.US).format(amount));
System.out.println(NumberFormat.getIntegerInstance(Locale.US).format(2.5));
NumberFormat c = NumberFormat.getCompactNumberInstance(Locale.US, NumberFormat.Style.SHORT);
System.out.println(c.format(1_500));
System.out.println(c.format(12_000));

Step 1. A percent instance multiplies by 100 and rounds to an integer: 25.6 becomes 26%.

Step 2. An integer instance rounds with HALF_EVEN: 2.5 goes to the even neighbor 2.

Step 3. The compact short instance has zero fraction digits by default: 1,500 is 1.5K, rounded half-even to 2K. 12,000 is 12K.

Solution. The four lines print 26%, 2, 2K and 12K.

Why the other choices are wrong. 25.6% forgets that the percent instance shows no fraction digits unless configured. 3 for the second line assumes HALF_UP, the rounding most people use by hand. 1.5K requires setMaximumFractionDigits(1).

Common pitfalls and exam tips

  • Exam tip: separate requested-locale selection, the optional default-locale retry, and base fallback. Once a localized bundle is selected, missing keys follow its parents, not another language's bundle.
  • MissingResourceException is unchecked; ParseException (from parse) is checked; DateTimeParseException is unchecked.
  • getString on a non-String value from a ListResourceBundle throws ClassCastException.
  • Currency formatting changes the symbol and the format, never the value.
  • With a JDK integer formatter using HALF_EVEN, 0.5 rounds to 0, 1.5 to 2, and 2.5 to 2. Set the mode explicitly when the rounding policy is part of a requirement.
  • Locale constructors are deprecated in favor of Locale.of and Locale.forLanguageTag.
  • MessageFormat: double the apostrophe, and argument indexes start at 0.
  • Date patterns are case-sensitive: mm is minutes, MM is months.
  • Use Locale.ROOT for case conversion of identifiers.
  • The default locale is global JVM state; a test that changes it can affect other tests.

Mini case study: localizing the checkout page

Scenario. The checkout page shows a title, the cart summary ("You have N items"), the total price and the delivery date. Customers are in the US (en_US), France (fr_FR) and Japan (ja_JP). The team only has English and French translations for now. A Japanese customer must still see a working page.

Questions. (1) How should resource files be named? (2) How should the total and the delivery date be formatted? (3) What happens for the Japanese customer? (4) What problem would arise if the server's default locale is fr_FR and an English customer's bundle is missing?

Solution.

  1. Create Checkout.properties (English, used as the safe default), Checkout_fr.properties for French and, if needed, Checkout_fr_CA.properties for differences. Put the summary pattern in each file with a MessageFormat placeholder, for example summary=You have {0} items. and summary=Vous avez {0} articles..
  2. For the total use NumberFormat.getCurrencyInstance(customerLocale) and make sure the amount has been converted into that currency beforehand, because formatting does not convert. For the date use DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(customerLocale).
  3. There is no Japanese bundle, so the lookup tries Checkout_ja_JP, Checkout_ja, then the default locale files, then falls back to the base Checkout.properties, which is English. The customer sees English text, but the numbers and dates are still formatted in the Japanese style with the ja_JP locale passed to the formatters. If the server default were fr_FR, the French file would be selected before the base file, so the Japanese customer would see French.
  4. Relying on the server default produces surprises: an English customer without Checkout_en would receive French text because Checkout_fr is found through the default locale step before the base bundle. Passing the customer's locale and providing an English base are necessary but do not by themselves disable fallback to French. In class-path applications, use ResourceBundle.getBundle("Checkout", customerLocale, ResourceBundle.Control.getNoFallbackControl(ResourceBundle.Control.FORMAT_DEFAULT)) when the base must be the final fallback. Control-based overloads are not supported for callers in named modules; use the module-aware resource-bundle/provider mechanisms there.

Key terms

  • Locale: an object that identifies a language and optionally a region, script and variant.
  • Resource bundle: a set of locale-specific key/value resources.
  • Base name: the name shared by all bundle files of a family, such as Messages.
  • Parent bundle: the less specific bundle consulted when a key is missing.
  • Localization (l10n): adapting an application to a locale; internationalization (i18n) is preparing it for adaptation.
  • NumberFormat: the abstract class for locale-aware number, currency and percentage formats.
  • CompactNumberFormat: a format that abbreviates numbers ("1K", "1 thousand").
  • MessageFormat: a class that fills a text pattern with arguments.

Summary checklist

  • I can create a Locale with constants, Locale.of, forLanguageTag and the builder.
  • I know the default locale and the DISPLAY and FORMAT categories.
  • I can predict which bundle getBundle selects, including the optional default-locale retry and the separate parent chain.
  • I know the difference between properties bundles and ListResourceBundle, and MissingResourceException.
  • I can use NumberFormat for numbers, currencies, percentages and compact numbers, and I can configure the JDK formatter's HALF_EVEN rounding.
  • I can format dates with DateTimeFormatter.ofLocalizedDate and ofPattern with a locale.
  • I can use MessageFormat including quotes and choice.

Official sources