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
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(Locale.US); // United States (explicit display locale)
us.getDisplayName(Locale.FRANCE); // anglais (États-Unis)
us.toString(); // en_US
us.toLanguageTag(); // en-USKey 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). 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.Locale.Builderchecks syntactic well-formedness, not whether a code is registered. 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=AlloResourceBundle 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).
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:
- Inspect requested-locale candidates
Messages_fr_CA,Messages_fr, and the baseMessages. A localized French match takes priority. A base-only match is held as a last resort. - 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. - 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:
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.JAPANshows yen and does not exchange anything. - The JDK's
DecimalFormatandCompactNumberFormatdefaults useHALF_EVEN; this is not a rounding guarantee for every possibleNumberFormatimplementation.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.setCurrency(Currency.getInstance("EUR"))changes currency identity, not exchange value. OnDecimalFormatit does not automatically adjust fraction-digit limits; configure those when switching between currencies such as JPY and EUR.NumberFormatandMessageFormatare 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:
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"); // ParseExceptionparse 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:
String input = "1,234.56abc";
ParsePosition pos = new ParsePosition(0);
Number value = us.parse(input, pos);
boolean complete = value != null && pos.getIndex() == input.length(); // falseCompact 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.5KThe 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.
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 2026Rules:
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 year-of-era,uuuuis proleptic year, andYYYYis week-based year;ddis day-of-month;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 with
ofPatternusesResolverStyle.SMARTby default, which can adjust some invalid day-of-month values. For strict date validation, useDateTimeFormatter.ofPattern("dd/MM/uuuu", Locale.ROOT).withResolverStyle(ResolverStyle.STRICT). With that formatter,LocalDate.parse("30/02/2026", formatter)throws the uncheckedDateTimeParseException. - 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.
DateTimeFormatteris immutable and thread-safe.
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 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 sectionThe 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 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?
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: 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.
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.
- 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.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. 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, useResourceBundle.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
Localewith constants,Locale.of,forLanguageTagand the builder. - I know the default locale and the
DISPLAYandFORMATcategories. - I can predict which bundle
getBundleselects, including the optional default-locale retry and the separate parent chain. - I know the difference between properties bundles and
ListResourceBundle, andMissingResourceException. - I can use
NumberFormatfor numbers, currencies, percentages and compact numbers, and I can configure the JDK formatter'sHALF_EVENrounding. - 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)
- Official Java SE 21 learning path: https://learn.oracle.com/ols/learning-path/become-a-java-se-21-developer/117252/138845 (opens in a new tab)
PropertyResourceBundleencoding: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/PropertyResourceBundle.html (opens in a new tab)DecimalFormatrounding and currency: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/text/DecimalFormat.html (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)
