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
Localeobjects, and explain the default locale and the categories of default locales. - Create
ResourceBundles from properties files and fromListResourceBundleclasses, and predict the lookup order, including fallback and parent chains. - Format and parse numbers, currencies and percentages with
NumberFormat, and compact numbers withCompactNumberFormat. - Format and parse dates and times with
DateTimeFormatterin a locale. - Build messages with
MessageFormat, including choice and number arguments. - Avoid common mistakes with encodings, locale-sensitive
Stringmethods 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). TheLocalefactories 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()andforLanguageTag()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.DISPLAYandLocale.Category.FORMAT, which can be set separately withLocale.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()andtoLowerCase()use the default locale. In a Turkish locale"title".toUpperCase()givesTİTLE(with a dotted capital I). UsetoUpperCase(Locale.ROOT)orLocale.ENGLISHfor 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:
Messages_fr_CAMessages_frMessages_en_US(the default locale)Messages_enMessages(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.JAPANshows yen and does not exchange anything. - The default rounding mode is
HALF_EVEN.getIntegerInstance().format(2.5)is2, andformat(3.5)is4. Change it withsetRoundingMode. setMaximumFractionDigits,setMinimumFractionDigitsandsetGroupingUsed(false)customize the output.NumberFormat.getCurrencyInstancefor 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:
ofLocalizedDateaccepts only date-based styles; with aLocalTimeit throwsUnsupportedTemporalTypeException.ofLocalizedTimeneeds a time.ofLocalizedDateTimeneeds both.FormatStyle.LONGandFULLfor times need a zone, so they fail on aLocalDateTime(DateTimeException).- The pattern letters are case-sensitive:
MMis month,mmis minutes;yyyyis the year,ddthe day;HHis the hour 0 to 23,hhis 1 to 12 withafor 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 uncheckedDateTimeParseException. - 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.
MissingResourceExceptionis unchecked;ParseException(fromparse) is checked;DateTimeParseExceptionis unchecked.getStringon a non-Stringvalue from aListResourceBundlethrowsClassCastException.- Currency formatting changes the symbol and the format, never the value.
NumberFormatusesHALF_EVEN: 0.5 rounds to 0, 1.5 to 2, 2.5 to 2.- Locale constructors are deprecated in favor of
Locale.ofandLocale.forLanguageTag. MessageFormat: double the apostrophe, and argument indexes start at 0.- Date patterns are case-sensitive:
mmis minutes,MMis months. - Use
Locale.ROOTfor 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.
- Create
Checkout.properties(English, used as the safe default),Checkout_fr.propertiesfor French and, if needed,Checkout_fr_CA.propertiesfor differences. Put the summary pattern in each file with aMessageFormatplaceholder, for examplesummary=You have {0} items.andsummary=Vous avez {0} articles.. - 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 useDateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(customerLocale). - There is no Japanese bundle, so the lookup tries
Checkout_ja_JP,Checkout_ja, then the default locale files, then falls back to the baseCheckout.properties, which is English. The customer sees English text, but the numbers and dates are still formatted in the Japanese style with theja_JPlocale passed to the formatters. If the server default werefr_FR, the French file would be selected before the base file, so the Japanese customer would see French. - Relying on the server default produces surprises: an English customer without
Checkout_enwould receive French text becauseCheckout_fris 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 useResourceBundle.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
Localewith constants,Locale.of,forLanguageTagand the builder. - I know the default locale and the
DISPLAYandFORMATcategories. - I can predict which bundle
getBundleselects, including the default-locale step and parents. - I know the difference between properties bundles and
ListResourceBundle, andMissingResourceException. - I can use
NumberFormatfor numbers, currencies, percentages and compact numbers, and I know theHALF_EVENdefault. - I can format dates with
DateTimeFormatter.ofLocalizedDateandofPatternwith a locale. - I can use
MessageFormatincluding quotes andchoice.
Official sources
- Oracle exam page 1Z0-830 (objectives): https://education.oracle.com/java-se-21-developer-professional/pexam_1Z0-830 (opens in a new tab)
- Java SE 21 Internationalization guide: https://docs.oracle.com/en/java/javase/21/intl/internationalization-overview.html (opens in a new tab)
ResourceBundleAPI: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/ResourceBundle.html (opens in a new tab)LocaleAPI: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Locale.html (opens in a new tab)NumberFormatAPI: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/text/NumberFormat.html (opens in a new tab)DateTimeFormatterAPI: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/format/DateTimeFormatter.html (opens in a new tab)MessageFormatAPI: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/text/MessageFormat.html (opens in a new tab)