Skip to content

Implementing Localization

Step 19 of 20

Implementing Localization

Introduction

This lesson covers the tenth and last objective of the Oracle Certified Professional Java SE 21 Developer exam (1Z0-830): Implementing Localization. Oracle describes it as implementing localization with locales and resource bundles, and parsing and formatting messages, dates, times and numbers, including currency and percentage values. Oracle does not publish a weighting per objective, so treat it as a smaller but very predictable topic: questions show a few lines using Locale, ResourceBundle, NumberFormat, DateTimeFormatter or MessageFormat, and ask which bundle file is selected, which text is printed, or which exception is thrown.

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.

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();       // United States (in the default locale)
us.getDisplayName(Locale.FRANCE); // anglais (États-Unis)
us.toString();                // en_US
us.toLanguageTag();           // en-US

Notes the exam likes:

  • Language codes are lowercase ISO 639 (en), country codes are uppercase ISO 3166 (US). 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.
  • 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.

# 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
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)

Since Java 9, properties bundles are read as UTF-8 (falling back to ISO-8859-1 only if the file is not valid UTF-8). Escapes like é still work.

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).

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 ResourceBundle.getBundle("Messages", new Locale("fr", "CA")) with default locale en_US, the search is:

  1. Messages_fr_CA
  2. Messages_fr
  3. Messages_en_US (the default locale)
  4. Messages_en
  5. Messages (the base bundle)

So the specific requested locale is tried first (from most to least specific), then the default locale (also from most to least specific), and only then the base bundle. The first match becomes the bundle; its parents are found by removing the most specific part, ending with the base bundle. A key missing in the chosen bundle is looked up in its parents.

If the match is Messages_fr_CA and a key is defined only in Messages_fr and Messages, the lookup returns the Messages_fr value. If a classes and properties file exist with the same name and level, the class wins over the properties file. If no bundle at all is found, MissingResourceException is thrown.

A trap: if the requested locale has no bundle but the default locale does, the default locale's bundle is used before the base bundle. A French request on an English default system and a missing Messages_fr file therefore yields Messages_en, not Messages.

3. Formatting numbers

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

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); // ¥1,235 or ¥1,235
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 default rounding mode is HALF_EVEN. 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.
  • A currency instance can be switched with setCurrency(Currency.getInstance("EUR")).

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

NumberFormat fr = NumberFormat.getInstance(Locale.FRANCE);
Number n = fr.parse("1 234,5");   // 1234.5 (may be Long or Double)
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.

Compact numbers

CompactNumberFormat (Java 12) abbreviates large numbers:

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

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

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 the year, dd the day; 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: LocalDate.parse("09/10/2026", DateTimeFormatter.ofPattern("dd/MM/yyyy")). A bad value throws the unchecked DateTimeParseException.
  • The result of localized styles can vary with the JDK's locale data (CLDR is the default since Java 9), so exam questions usually avoid exact output of the long or full forms, except for common ones such as US SHORT.

5. Messages with MessageFormat

MessageFormat inserts arguments into a pattern with {index}.

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 October 9, 2026.

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:

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 MessageFormat.format(rb.getString("cart.summary"), count). 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?

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?

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: for bundle questions, write the candidate list on paper: requested locale (specific to general), default locale (specific to general), base. The first existing file is the match; its parents fill missing keys.
  • 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.
  • NumberFormat uses HALF_EVEN: 0.5 rounds to 0, 1.5 to 2, 2.5 to 2.
  • 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. The fix is to pass the customer's locale everywhere, to provide an explicit base bundle in the language you want as the final fallback, and, if needed, to use ResourceBundle.Control.getNoFallbackControl(...) to disable the default-locale step.

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 default-locale step and parents.
  • I know the difference between properties bundles and ListResourceBundle, and MissingResourceException.
  • I can use NumberFormat for numbers, currencies, percentages and compact numbers, and I know the HALF_EVEN default.
  • I can format dates with DateTimeFormatter.ofLocalizedDate and ofPattern with a locale.
  • I can use MessageFormat including quotes and choice.

Official sources