Converters

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Everything a browser sends is text, and everything a backing bean holds is a typed Java object. A converter is the object that translates between the two: getAsObject() turns the submitted string into a model value, and getAsString() turns a model value into the string that is written into the page. Without converters every bean property would have to be a String. This page is written against Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11), Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, with Java 17 or later. The converter API used below is the same in Faces 4.0 (Jakarta EE 10) apart from UUIDConverter, which is new in 4.1; the examples were not run on 4.0. Faces 5.0 is still an upcoming release (latest milestone 5.0.0-M9) and is not described as current behaviour. Every example and every output quoted on this page was run for real: the files were deployed in a scratch Bookshelf WAR served by Tomcat 11.0.26 with Weld 6.0.4.Final and Mojarra 4.1.17 (JDK 26 with --release 21, English locale), the same files were posted to MyFaces 4.1.4 where the text says so, and the review form was driven in a real browser (checked on 2026-10-08).

The running scenario is the review form of the Bookshelf: a reader enters the ISBN of a book, picks an author, rates the book, gives the date the book was read, the price paid and a few tags. Each of those fields needs a different converter, which makes the form a good tour of the whole topic.

Where Conversion Happens

Conversion is part of the request processing lifecycle (see the six phases) and it runs at both ends of a round trip:

  • Apply Request Values (phase 2). The renderer of each input component reads the string from the request and stores it unchanged as the component’s submitted value. Nothing is converted yet.

  • Process Validations (phase 3). UIInput.validate() takes the submitted value and converts it into the local value by calling getAsObject() (through the renderer’s getConvertedValue()). Only if the conversion succeeds do the validators run, and they receive the converted object, not the string.

  • Update Model Values (phase 4). The converted local value is written to the bean property, for example review.setRating(Integer). This phase is skipped when any component of the executed part of the form has a conversion or validation error.

  • Render Response (phase 6). getAsString() runs on every output component and on every input component whose value is not being redisplayed from an error, and its result is written into the HTML.

sequenceDiagram autonumber participant B as Browser participant R as Renderer participant I as UIInput participant C as Converter participant M as Bean property B->>R: POST with the field as a string R->>I: Apply Request Values, setSubmittedValue(string) I->>C: Process Validations, getAsObject(string) alt the converter returns an object C-->>I: typed value I->>I: run the validators on the typed value I->>M: Update Model Values, setter(typed value) else the converter throws ConverterException C-->>I: ConverterException with a FacesMessage I->>I: message queued, component marked invalid Note over I,M: the model is not touched and the lifecycle jumps to Render Response end M-->>C: Render Response, getAsString(typed value) C-->>B: string written into the page

Two consequences follow. A converter always sees the request’s locale: Faces requires converters that need a locale to take it from the UIViewRoot of the current FacesContext, so changing the locale of the view changes how numbers and dates are parsed and printed. And because validation happens on the converted value, the order of the errors on a field is always "conversion first, validation second": a field that fails conversion never reaches its validators.

The specification also asks converters to be symmetric: getAsObject(getAsString(x)) should give back a value equal to x. When that is impossible (a java.text.Format that prints two adjacent numbers with no separator, for example) the converter should at least document it.

How Faces Finds the Converter

For each UIInput and UIOutput component Faces uses the first converter it finds in this order:

  1. A converter set explicitly on the component: the converter attribute, a nested f:converter, f:convertNumber or f:convertDateTime tag, or setConverter() in Java.

  2. Otherwise, a converter registered for the type of the value expression. Faces calls getType() on the value expression of the value attribute and asks Application.createConverter(Class) for a converter registered for that class.

  3. If the type is String or Object, or there is no value expression, no conversion takes place and the string is used as it is.

flowchart TD A["Submitted string for a UIInput"] --> B{"Converter set on the component?"} B -- "yes" --> C["Use it: getAsObject on the string"] B -- "no" --> D{"Value expression with a property type?"} D -- "no" --> E["No conversion, keep the string"] D -- "yes" --> F{"Type is String or Object?"} F -- "yes" --> E F -- "no" --> G{"Converter registered for that type?"} G -- "yes" --> C G -- "no" --> H["Keep the string and let the model update fail"]

The last box of the flowchart is the one that surprises people, and it behaves differently in the two implementations; see Implicit Conversion. Select components follow the same rules, with one extra source of information for the converter: the type of the values of their f:selectItem and f:selectItems children (see Entity Converters for Select Menus).

A plain expression in the page text is not a Faces component and never uses a converter. In the scratch run <li>isbn=[#{reviewBean.isbn}]</li> printed the toString() of the record (Isbn[digits=9780306406157]), while <h:outputText value="#{reviewBean.isbn}"/> printed 978-0-306-40615-7 because h:outputText is a UIOutput and found the converter registered for Isbn.

The Standard Converters

Faces registers a converter for every Java type that a form commonly needs. They live in the jakarta.faces.convert package, and each has a constant converter id (CONVERTER_ID):

Class Converter id Registered by type for

BigDecimalConverter

jakarta.faces.BigDecimal

BigDecimal

BigIntegerConverter

jakarta.faces.BigInteger

BigInteger

BooleanConverter

jakarta.faces.Boolean

Boolean, boolean

ByteConverter

jakarta.faces.Byte

Byte, byte

CharacterConverter

jakarta.faces.Character

Character, char

DateTimeConverter

jakarta.faces.DateTime

by id only (the f:convertDateTime tag)

DoubleConverter

jakarta.faces.Double

Double, double

EnumConverter

jakarta.faces.Enum

any Enum

FloatConverter

jakarta.faces.Float

Float, float

IntegerConverter

jakarta.faces.Integer

Integer, int

LongConverter

jakarta.faces.Long

Long, long

NumberConverter

jakarta.faces.Number

by id only (the f:convertNumber tag)

ShortConverter

jakarta.faces.Short

Short, short

UUIDConverter

jakarta.faces.UUID

java.util.UUID (new in 4.1)

The tutorial’s list of converter ids stops at jakarta.faces.Short; the 4.1 specification adds jakarta.faces.UUID, and the 4.1 API documents UUIDConverter with "Since: 4.1". The specification text also says "java.lang.UUID" where it means java.util.UUID, which is the class the converter handles. A compliant implementation additionally has to accept the registration of a converter for String.

Notice what is not in the table: LocalDate, LocalDateTime, Date, List, any entity or value object. They have no converter by type, so you attach one (f:convertDateTime, your own converter) or the property cannot be bound to an input field (see Implicit Conversion).

There are four ways to attach a converter to a component:

  1. Do nothing, and rely on the type registration. This is the way most fields work.

  2. Nest f:convertNumber or f:convertDateTime, the two standard converters that have their own tag because they need configuration.

  3. Put the converter id in the converter attribute: converter="jakarta.faces.Integer" or converter="bookshelf.author". The attribute also accepts an EL expression that evaluates to a Converter instance, which is how a converter exposed as a CDI bean is attached (see Custom Converters).

  4. Nest f:converter with converterId (a converter id) or binding (an EL expression that evaluates to a Converter, typically a bean property).

<!-- 1. by type: Integer property, IntegerConverter is chosen automatically -->
<h:inputText id="rating" value="#{review.rating}"/>

<!-- 3. the converter attribute with a converter id -->
<h:inputText id="pages" value="#{review.pages}" converter="jakarta.faces.Integer"/>

<!-- 4. f:converter with converterId, which also allows siblings such as f:validateLongRange -->
<h:inputText id="votes" value="#{review.votes}">
  <f:converter converterId="jakarta.faces.Long"/>
</h:inputText>

The tutorial says the converter attribute accepts "the identifier or the fully-qualified class name" of the converter. In the scratch run, converter="org.example.bookshelf.web.IsbnConverter" failed on both implementations (Mojarra: Named Object: ... not found, MyFaces: Could not find any registered converter-class by converterId), but that converter was registered only for its class with forClass. Use the converter id, or a binding/EL expression, and do not rely on the class name.

Implicit Conversion

Implicit conversion is the by-type registration at work, and it is why a Integer bean property needs no tag at all. These are the observed results of posting the Bookshelf test page (a request-scoped bean, Mojarra 4.1.17), with the bean properties of the type shown:

Property Submitted Result

Integer rating

4

4

Integer rating

empty string

null, no error

Integer rating

abc

error: "'abc' must be a number consisting of one or more digits."

int pages

empty string

0, no error (the converter returns null and the EL coercion of null to a primitive gives 0)

int pages

99999999999

error: out of the range of int

Boolean recommended

checkbox unchecked

false, not null (the browser sends nothing and the checkbox renderer decodes false)

Genre genre (enum)

SCIENCE

Genre.SCIENCE

Genre genre (enum)

POETRY

error: "'POETRY' must be convertible to an enum."

UUID bookId

123e4567-e89b-12d3-a456-426614174000

the UUID

UUID bookId

zzz

error: "'zzz' must be a UUID."

String title

empty string

"" (an empty string, not null)

The last row is a classic source of bugs. Faces by default stores an empty text field in a String property as "", so a check such as title == null never fires. Set the context parameter jakarta.faces.INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL to true in web.xml and an empty field becomes null (verified: with the parameter true the same post left title null, without it ""; see the context parameters). Bean Validation’s @NotNull and @NotBlank are the other way to express the rule; see the validators and Bean Validation pages.

A type with no converter

LocalDate, LocalDateTime and Date properties do not get a converter by type. If you bind a LocalDate property to an input field without f:convertDateTime, the string cannot be assigned. What you see depends on the implementation, and it is one of the most visible differences between them:

Implementation What happened for LocalDate readOn with the value 2026-10-07

Mojarra 4.1.17

A message on the page: "Conversion Error setting value '2026-10-07' for 'null Converter'." The same message appeared for an empty field.

MyFaces 4.1.4

No message: the request ended with an error page, jakarta.el.ELException: Cannot convert [2026-10-07] of type [class java.lang.String] to [class java.time.LocalDate].

The fix is the same on both: attach f:convertDateTime with a java.time type or a custom converter, as shown below.

f:convertNumber

f:convertNumber registers a NumberConverter (id jakarta.faces.Number) on the nearest parent component. It formats and parses numbers, currencies and percentages with java.text.NumberFormat or a DecimalFormat pattern. The attributes, from the 4.1 VDL documentation:

Attribute Meaning

type

number (default), currency or percent

pattern

A java.text.DecimalFormat pattern. When set, type and the digit options are ignored

locale

A Locale or a language tag string such as de_DE. Default: the locale of the UIViewRoot

currencyCode

ISO 4217 code such as EUR, used only for currencies

currencySymbol

Symbol to print instead of the locale’s, used only for currencies

groupingUsed

Whether grouping separators are printed. Default true

integerOnly

Whether only the integer part is formatted and parsed. Default false

minIntegerDigits, maxIntegerDigits

Bounds on the integer digits

minFractionDigits, maxFractionDigits

Bounds on the fraction digits

binding

A NumberConverter held by a bean property

for

Used inside a composite component to name an attached object

These outputs were produced by h:outputText with fixed numbers (default locale en_US):

Tag Output

<f:convertNumber/> on 1234567.891

1,234,567.891

<f:convertNumber locale="de_DE" minFractionDigits="2" maxFractionDigits="2"/>

1.234.567,89

<f:convertNumber groupingUsed="false" maxFractionDigits="1"/>

1234567.9

<f:convertNumber type="currency" currencyCode="USD"/> on 12.5

$12.50

<f:convertNumber type="currency" currencyCode="EUR" locale="es_ES"/>

12,50 € (a no-break space before the euro sign)

<f:convertNumber type="currency" currencySymbol="$" locale="en_US"/>

$12.50

<f:convertNumber type="percent"/> on 0.256

26%

<f:convertNumber type="percent" minFractionDigits="1"/>

25.6%

<f:convertNumber pattern="#,##0.000"/> on 1234.5

1,234.500

<f:convertNumber minIntegerDigits="3" groupingUsed="false"/> on 7

007

<h:outputText value="#{book.price}">
  <f:convertNumber type="currency" currencyCode="EUR" locale="es_ES"/>
</h:outputText>

<h:inputText id="price" label="Price" value="#{review.paidPrice}">
  <f:convertNumber pattern="#,##0.00" locale="en"/>
</h:inputText>

Parsing is where the details matter. These were verified with bean properties Double ratio, BigDecimal amount and Long votes:

  • A BigDecimal property with f:convertNumber receives a BigDecimal (1,234.567 became 1234.567). The converter parses to a Double or Long and the value is coerced to the property type, so for money prefer BigDecimal and a pattern, and be aware of the intermediate double.

  • type="percent" parses 25% to 0.25 and rejects 0.25 ("could not be understood as a percentage") and, with a space, 25 %.

  • type="currency" requires the currency symbol: with locale="es_ES" and currencyCode="EUR", 1.234,50 € (with a normal or a no-break space) was accepted and 1234,5 or 12 EUR was rejected.

  • integerOnly="true" did not change the formatted output of 12.99 (it printed 12.99), but when parsing 12.99 into a Long the result was 12. In practice it is a parse option.

  • maxFractionDigits is a formatting bound. 1,234.567 was accepted with maxFractionDigits="2" on input.

  • Mojarra parses leniently, MyFaces strictly. With Mojarra 12abc was accepted as 12, and 1.234,5 in the en locale was accepted as 1.234, silently losing the rest. MyFaces rejected both with "is not a number". Do not rely on the converter alone to reject trailing text: add a f:validateRegex or a pattern check, or write a strict custom converter, when the input must be exact.

f:convertDateTime and java.time

f:convertDateTime registers a DateTimeConverter (id jakarta.faces.DateTime). Since Faces 2.3 it handles the java.time types as well as java.util.Date; the type attribute chooses the formatter:

type Property type Default format when no pattern is given

date (default)

java.util.Date

DateFormat.getDateInstance(dateStyle)

time

java.util.Date

DateFormat.getTimeInstance(timeStyle)

both

java.util.Date

DateFormat.getDateTimeInstance(dateStyle, timeStyle)

localDate

LocalDate

DateTimeFormatter.ofLocalizedDate(dateStyle)

localTime

LocalTime

DateTimeFormatter.ofLocalizedTime(dateStyle)

localDateTime

LocalDateTime

DateTimeFormatter.ofLocalizedDateTime(dateStyle)

offsetTime

OffsetTime

DateTimeFormatter.ISO_OFFSET_TIME

offsetDateTime

OffsetDateTime

DateTimeFormatter.ISO_OFFSET_DATE_TIME

zonedDateTime

ZonedDateTime

DateTimeFormatter.ISO_ZONED_DATE_TIME

The other attributes are dateStyle and timeStyle (default, short, medium, long, full; with a java.time formatter default means medium), pattern, locale, timeZone, binding and for. When pattern is set, dateStyle, timeStyle and (for java.util.Date) type are ignored; for the java.time types the pattern is passed to DateTimeFormatter.ofPattern(pattern, locale), and for java.util.Date to SimpleDateFormat. Parsing is non-lenient: the string must match the format exactly.

Verified outputs (a Date of the epoch, rendered with timeZone="UTC", and LocalDate 2026-10-07):

Tag Output

type="date" dateStyle="full" timeZone="UTC" locale="en"

Thursday, January 1, 1970

type="both" dateStyle="short" timeStyle="short" timeZone="UTC" locale="es"

1/1/70, 0:00

pattern="EEEE d MMMM yyyy" timeZone="UTC" locale="es"

jueves 1 enero 1970

type="localDate" dateStyle="medium" locale="en"

Oct 7, 2026

type="localDate" dateStyle="short" locale="es"

7/10/26

type="localDate" pattern="dd MMM yyyy" locale="fr"

07 oct. 2026

type="localDateTime" dateStyle="medium" timeStyle="short" locale="en"

Oct 7, 2026, 3:15 PM (see the next paragraph)

type="localDateTime" pattern="yyyy-MM-dd'T'HH:mm"

2026-10-07T15:15

type="zonedDateTime" on 2026-10-07 15:15 Europe/Madrid

2026-10-07T15:15:00+02:00\[Europe/Madrid]

type="zonedDateTime" pattern="yyyy-MM-dd HH:mm z" locale="en"

2026-10-07 15:15 CEST

<h:inputText id="readOn" label="Date read" value="#{review.readOn}">
  <f:convertDateTime type="localDate" pattern="yyyy-MM-dd"/>
</h:inputText>

<h:outputText value="#{book.publishedAt}">
  <f:convertDateTime type="localDate" dateStyle="long"/>
</h:outputText>

Four traps appear with dates, all verified:

  • The localized time formats contain a narrow no-break space. With JDK 20 and later, dateStyle="medium" with timeStyle="short" in the en locale prints 3:15 PM with U+202F between the time and PM, and the time-only format printed 12:00:00 AM the same way. A user who types a normal space gets "could not be understood as a date and time" (the example the converter prints in the error message contains the same invisible character). Use a pattern for anything a person has to type; use the styles only for output.

  • The default time zone is GMT, not the server’s. A java.util.Date of the epoch printed 1970-01-01 00:00 GMT with no timeZone attribute, and 1970-01-01 01:00 CET with timeZone="Europe/Madrid", on a JVM whose own zone was CEST. Faces defaults to GMT for Date conversion because the specification says so. Set jakarta.faces.DATETIMECONVERTER_DEFAULT_TIMEZONE_IS_SYSTEM_TIMEZONE to true in web.xml and the same tag without timeZone printed 1970-01-01 01:00 CET. The java.time types carry their own zone information and are not affected.

  • type="date" cannot format a LocalDate. <f:convertDateTime type="date"/> on a LocalDate ended in ConverterException: ... Could not convert '2026-10-07' to a string, caused by IllegalArgumentException: Cannot format given Object as a Date. Match the type to the property type.

  • h:outputText of a LocalDate with no converter prints toString() (2026-10-07) and does not fail; it is only the input direction that has no converter.

UUIDConverter and EnumConverter

UUIDConverter is new in Faces 4.1 (java.util.UUID properties, id jakarta.faces.UUID). A UUID bean property bound to an input field needs no tag: the value 123e4567-e89b-12d3-a456-426614174000 was converted, and zzz was rejected with "'zzz' must be a UUID. Example: 29573f55-4254-4afa-9ca6-6b5ae6c7ab6e". In Faces 4.0 and earlier the same field needs a custom converter.

EnumConverter has been available since 1.2 and converts any enum to its name() and back. It is the converter behind a menu of constants:

public enum Genre {
    FICTION, SCIENCE, HISTORY
}
<h:selectOneMenu id="genre" value="#{review.genre}">
  <f:selectItem itemLabel="Any" noSelectionOption="true"/>
  <f:selectItems value="#{review.genres}"/>
</h:selectOneMenu>

With getGenres() returning Genre.values() the menu rendered options whose values are the constant names (FICTION, SCIENCE, HISTORY). Posting POETRY (a value the menu never offered) produced the message "'POETRY' must be convertible to an enum from the enum that contains the constant 'POETRY'", which is also what a tampered request looks like. Labels come from itemLabel (or toString() of the constant): write the display text in a resource bundle, not in the enum’s name(), because name() is the value that travels in the form.

The upcoming Faces 5.0 lists, among its planned issues, generics for the standard converters and an EnumConverter that finds the enum type from the value expression. Both are planned changes in a milestone, not current behaviour.

Custom Converters

When the standard converters do not know your type, write your own. A converter is a class that implements jakarta.faces.convert.Converter<T> and its two methods:

  • T getAsObject(FacesContext context, UIComponent component, String value), called in Process Validations. It must return null for a null or empty string (an empty optional field is not an error) and throw a ConverterException for text it cannot convert.

  • String getAsString(FacesContext context, UIComponent component, T value), called in Render Response. It must return an empty string for null.

The interface in 4.1 is generic. The class must have a public no-argument constructor (or, for a per-class converter, one that takes a Class, which is how EnumConverter receives its enum type), and a new instance is created wherever the converter is used. The specification says converters need not be thread-safe because they are only used inside one request thread, and it follows that a converter exposed as an @ApplicationScoped bean (see below) must be written to be stateless.

The Bookshelf identifies a book by its ISBN-13, which the application models as a small value type:

public record Isbn(String digits) implements Serializable {

    /** Formats the digits as 978-0-306-40615-7 (display only). */
    public String formatted() {
        return digits.substring(0, 3) + "-" + digits.substring(3, 4) + "-"
                + digits.substring(4, 7) + "-" + digits.substring(7, 12) + "-" + digits.substring(12);
    }
}
@FacesConverter(forClass = Isbn.class)
public class IsbnConverter implements Converter<Isbn> {

    @Override
    public Isbn getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        String digits = value.replaceAll("[-\\s]", "");
        if (!digits.matches("\\d{13}")) {
            FacesMessage message = new FacesMessage(FacesMessage.SEVERITY_ERROR,
                    "Not an ISBN-13", "'" + value + "' must contain exactly 13 digits (hyphens are allowed).");
            throw new ConverterException(message);
        }
        return new Isbn(digits);
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, Isbn value) {
        return value == null ? "" : value.formatted();
    }
}

With forClass the converter is registered by type, so every Isbn property in the application uses it with no tag at all, in input fields and in h:outputText. Run on the form, + 978-0-306-40615-7 ` converted to `+Isbn[digits=9780306406157], the stored value was shown again as 978-0-306-40615-7, and 12345 produced the message "Not an ISBN-13" with the detail text above.

Because the converter has no dependency on the container, a unit test needs no Faces runtime. The FacesContext and UIComponent arguments can be null when the converter does not use them (a converter that reads component attributes needs a real or mocked component):

class IsbnConverterTest {

    private final IsbnConverter converter = new IsbnConverter();

    @Test
    void stripsSeparators() {
        Isbn isbn = converter.getAsObject(null, null, " 978-0-306-40615-7 ");
        assertEquals("9780306406157", isbn.digits());
    }

    @Test
    void emptyIsNull() {
        assertNull(converter.getAsObject(null, null, ""));
    }

    @Test
    void roundTrip() {
        Isbn isbn = converter.getAsObject(null, null, "9780306406157");
        assertEquals("978-0-306-40615-7", converter.getAsString(null, null, isbn));
    }

    @Test
    void rejectsShortNumbers() {
        ConverterException e = assertThrows(ConverterException.class,
                () -> converter.getAsObject(null, null, "12345"));
        assertEquals("Not an ISBN-13", e.getFacesMessage().getSummary());
    }
}

The same four calls were run in a main method against the compiled converter, and printed Isbn[digits=9780306406157], null, 978-0-306-40615-7 and Not an ISBN-13 | ERROR 2; the JUnit 5 form shown here is the same code with assertions; the testing page compiles and runs the same four tests (all passed) and shows how to test a converter that reads component attributes.

Registering a converter

There are three registrations. The API documentation says the annotation ends in an Application.addConverter() call at start-up, which is what the XML element configures too:

  • @FacesConverter("bookshelf.tagList") registers the class by id. Components refer to it with converter="bookshelf.tagList" or <f:converter converterId="bookshelf.tagList"/>.

  • @FacesConverter(forClass = Isbn.class) registers it by type, as above. It is used automatically for properties of that class.

  • The faces-config.xml element <converter> with <converter-id> or <converter-for-class>, and <converter-class> (the 4.1 schema also has <converter-extension>). It replaces the annotation and is the only way to configure a converter whose class you cannot annotate. See the project setup page for the file.

One annotation cannot do both by id and by type. The API documentation says so, and suggests putting the logic in an abstract base class and deriving two classes, one with value and one with forClass.

<converter>
  <converter-id>bookshelf.tagList</converter-id>
  <converter-class>org.example.bookshelf.web.TagListConverter</converter-class>
</converter>

Converters with parameters

A converter often needs a setting that differs per field. There are three ways to give it one.

An attribute of the component. The converter reads component.getAttributes(), and the page author sets the value with f:attribute inside the component. This keeps the converter class independent of any bean:

@FacesConverter("bookshelf.tagList")
public class TagListConverter implements Converter<List<String>> {

    private static final String SEPARATOR_ATTRIBUTE = "separator";

    @Override
    public List<String> getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null || value.isBlank()) {
            return List.of();
        }
        String separator = separator(component);
        return Arrays.stream(value.split(Pattern.quote(separator)))
                .map(String::trim)
                .filter(s -> !s.isEmpty())
                .toList();
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, List<String> value) {
        return value == null ? "" : String.join(separator(component) + " ", value);
    }

    private String separator(UIComponent component) {
        Object configured = component.getAttributes().get(SEPARATOR_ATTRIBUTE);
        return configured == null ? "," : configured.toString();
    }
}
<h:inputText id="tags" value="#{review.tags}">
  <f:converter converterId="bookshelf.tagList"/>
  <f:attribute name="separator" value=";"/>
</h:inputText>

Posting sci-fi; classics ; space to this field gave the list [sci-fi, classics, space].

A property of the converter. A converter can also have JavaBean properties, which is how NumberConverter and DateTimeConverter take their pattern and locale. The tags f:convertNumber and f:convertDateTime set those properties. A converter with configuration that must survive across requests should also implement StateHolder (or PartialStateHolder), because the converter instance belongs to the component tree and its state is saved with the view (see the page on state saving).

A converter that is a CDI bean. Make the converter a named bean and attach it with an EL expression. The expression may also be used in f:converter binding:

@Named("slugConverter")
@ApplicationScoped
public class SlugConverter implements Converter<String> {

    @Override
    public String getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null) {
            return null;
        }
        return value.trim().toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-").replaceAll("(^-|-$)", "");
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, String value) {
        return value == null ? "" : value;
    }
}
<h:inputText id="slug" value="#{review.slug}" converter="#{slugConverter}"/>

The input + The Left Hand of Darkness! ` was stored as `+the-left-hand-of-darkness. The bean is shared by every request, so it holds no per-request state. When the converter needs to look something up, prefer the managed converter of the next section, which is created and injected by CDI and is not shared.

Managed Converters and CDI Injection

A converter instantiated by Faces is not a CDI bean, so @Inject does not work inside it. Run on Mojarra 4.1.17, a converter declared with the plain @FacesConverter("plainInjected") and an @Inject AuthorService field was created and called, but the field was null: the converter returned its marker for "not injected". The 4.1 API provides the fix, the managed element of the annotation: "an indicator that flags whether or not the given converter is a CDI managed converter". A managed converter is created and injected by CDI. The Bookshelf uses one to turn an author id from the form into an Author:

public record Author(Long id, String name) implements Serializable { }
@ApplicationScoped
public class AuthorService {

    private final List<Author> authors = List.of(
            new Author(1L, "Ursula K. Le Guin"),
            new Author(2L, "Italo Calvino"),
            new Author(3L, "Stanislaw Lem"));

    public List<Author> findAll() {
        return authors;
    }

    public Optional<Author> findById(long id) {
        return authors.stream().filter(a -> a.id() == id).findFirst();
    }
}
@Dependent
@FacesConverter(value = "bookshelf.author", managed = true)
public class AuthorConverter implements Converter<Author> {

    @Inject
    private AuthorService authors;

    @Override
    public Author getAsObject(FacesContext context, UIComponent component, String value) {
        if (value == null || value.isBlank()) {
            return null;
        }
        try {
            return authors.findById(Long.parseLong(value)).orElseThrow(
                    () -> new ConverterException(new FacesMessage(FacesMessage.SEVERITY_ERROR,
                            "Unknown author", "No author has the id " + value)));
        } catch (NumberFormatException e) {
            throw new ConverterException(new FacesMessage(FacesMessage.SEVERITY_ERROR,
                    "Invalid author id", "'" + value + "' is not an author id"), e);
        }
    }

    @Override
    public String getAsString(FacesContext context, UIComponent component, Author value) {
        return value == null ? "" : String.valueOf(value.id());
    }
}

The @Dependent line is not decoration. With bean-discovery-mode="annotated" in beans.xml (the mode the section’s project uses), @FacesConverter is a CDI qualifier and not a bean-defining annotation, so the class is not a bean until you give it a scope. Without it:

  • Mojarra reported Error - @FacesConverter("bookshelf.author", managed=true) on ...AuthorConverter could not be resolved by CDI. Check that beans.xml is present and that the BeanManager is fully initialized before FacesConverter instances are created.

  • MyFaces 4.1.4 did not report that message: the page failed at render time with a NullPointerException in FacesConverterCDIWrapper.getAsString because the wrapped converter was null.

Add @Dependent (a new instance per use, as for ordinary converters), or use bean-discovery-mode="all". Both implementations then behaved the same. Forgetting the scope is the most common reason a managed converter "does not work"; managed validators are covered on the validators page.

A managed converter can be registered by id (as here, converter="bookshelf.author") or by type (@FacesConverter(forClass = Author.class, managed = true)); the by-type form worked for a selectOneMenu and for a selectManyCheckbox bound to a List<Author> with no converter attribute at all, on Mojarra.

Entity Converters for Select Menus

Select components render a list of strings and need to map the chosen string back to the object. The selectOneMenu for the author is the standard case of an entity converter: the value of each f:selectItem is the object, the rendered option value is getAsString() (the id), and getAsObject() finds the object again from the id. How the lookup reaches a database (and why it needs a service, not a static DAO) is on the persistence page.

<h:selectOneMenu id="author" label="Author" value="#{review.author}" converter="bookshelf.author" required="true">
  <f:selectItem itemLabel="Choose an author" noSelectionOption="true"/>
  <f:selectItems value="#{review.authors}" var="a" itemValue="#{a}" itemLabel="#{a.name}"/>
</h:selectOneMenu>

The menu rendered <option value="1">Ursula K. Le Guin</option> and so on, and posting 2 set the property to the Author with id 2.

For a List<Author>, a selectManyCheckbox or selectManyListbox has a problem: the generic type of the property is erased at run time, so Faces cannot ask the property for its element type. The API documentation for h:selectManyListbox calls it out as a subtlety of converters and collections. Name the converter on the component (converter="bookshelf.author") or register one by type. Without any converter, the selectManyCheckbox test failed with two messages: "Conversion Error setting value '2' for 'null Converter'." for a single select, and "Validation Error: Value is not valid" for the many-select, because the posted strings no longer matched the item values.

Four rules make entity converters safe:

  • Convert to a key, not to an object graph. getAsString() returns the id, never a serialised entity or a toString() meant for people. Whatever the page shows is also visible and changeable by the browser, so the converter must treat the string as untrusted input.

  • Validate the key. An unknown id raised a ConverterException ("Unknown author") and a non-numeric one another ("Invalid author id"), instead of a NullPointerException or NumberFormatException reaching the user.

  • Check authorisation where it matters. Anyone can post any id. If a user may only choose some authors, the converter or a validator must check it; the fact that the menu offered only those authors is not a protection.

  • Keep getAsString() free of queries. It runs for every option of every menu in Render Response and should only read the id from the object it is given. The lookup in getAsObject() runs once per submitted value.

For a generic converter that serves every entity of an application (the usual answer to "I have forty entity classes"), the OmniFaces library offers ready-made select-item converters; they are covered with that library, on the OmniFaces page.

The tutorial’s Duke’s Tutoring case study registers @FacesConverter(forClass = Guardian.class, value = "guardian") and says the forClass attribute makes it the default converter for Guardian. The API documentation says the opposite: when value is not empty, only the id registration is made. That is what happened in the scratch run: with @FacesConverter(value = "bookshelf.author", forClass = Author.class, managed = true) and no converter attribute, the selectOneMenu failed with "Conversion Error setting value '2' for 'null Converter'", so the by-type registration had not been made. Use the id and the attribute, or forClass alone.

Conversion Errors and Messages

A converter reports failure by throwing jakarta.faces.convert.ConverterException. The constructors take a String or a FacesMessage (and optionally a cause). Prefer the FacesMessage form: it carries a severity, a summary and a detail. Faces catches the exception in UIInput.validate(), queues the message on the FacesContext for the client id of the component, marks the component invalid and moves on to Render Response, as the diagram at the start of this page shows. Four things are worth knowing:

  • The component keeps what the user typed. After a failed conversion the field is redisplayed with the submitted string and not with the old model value. That is why a form that fails to convert still shows the wrong input for the user to correct.

  • The model, the validators and the action are skipped. The bean setters and the action method were not called in the failed posts of the scratch run (the action’s flag stayed false); this is the usual "my button does nothing" cause. Always put an h:messages or an h:message where the user can see the error.

  • Only ConverterException is a conversion error. A converter that lets a NumberFormatException escape (a deliberately faulty test converter called Integer.parseInt on xx) produced the "An Error Occurred" page of the Development stage with the text For input string: "xx", not a field message. Catch and wrap.

  • A failing getAsString is not recoverable. It runs in Render Response, where the exception ends in the error page (the type="date" on a LocalDate example above). Converters should accept any value they were handed.

The message of a standard converter

Standard converters produce their messages from the Faces message resource, with a summary and a detail. These are the keys, and in each message {0} is the submitted value, {1} an example value, and {2} the label of the component (for some converters {1} is the label, and the placeholders are listed in the Javadoc of each message constant):

Key Default text (Mojarra 4.1.17, English)

jakarta.faces.converter.IntegerConverter.INTEGER

{2}: '{0}' must be a number consisting of one or more digits.

jakarta.faces.converter.IntegerConverter.INTEGER_detail

{2}: '{0}' must be a number between -2147483648 and 2147483647 Example: {1}

jakarta.faces.converter.NumberConverter.NUMBER

{2}: '{0}' is not a number.

jakarta.faces.converter.NumberConverter.CURRENCY

{2}: '{0}' could not be understood as a currency value.

jakarta.faces.converter.NumberConverter.PERCENT

{2}: '{0}' could not be understood as a percentage.

jakarta.faces.converter.NumberConverter.PATTERN

{2}: '{0}' is not a number pattern.

jakarta.faces.converter.DateTimeConverter.DATE

{2}: '{0}' could not be understood as a date.

jakarta.faces.converter.DateTimeConverter.DATETIME

{2}: '{0}' could not be understood as a date and time.

jakarta.faces.converter.EnumConverter.ENUM

{2}: '{0}' must be convertible to an enum.

jakarta.faces.converter.UUIDConverter.UUID

{2}: '{0}' must be a UUID.

jakarta.faces.converter.BooleanConverter.BOOLEAN

{1}: '{0}' must be 'true' or 'false'.

The keys have the same shape for every converter (jakarta.faces.converter.<Class>.<ID> with a _detail variant), and the other converters follow it. The label {2} is the component’s label attribute when it has one and the client id otherwise: the failed post printed f:pages: "y" is not a whole number... before a label="Page count" was added and Page count: "y" is not a whole number... after. Always set label on inputs so the messages read well.

Changing the message

There are three levels, from the narrowest to the widest.

One component: converterMessage. Every input component has the attributes converterMessage, validatorMessage and requiredMessage. When set, converterMessage replaces the message of any converter failure on that component, the standard converters' and your own custom converter’s alike (it replaced the summary and the detail, so the text appeared twice with showSummary and showDetail both on):

<h:inputText id="rating" label="Rating" value="#{review.rating}"
             converterMessage="The rating must be a whole number."/>
<h:message for="rating"/>

The whole application: a message bundle. Register a bundle with <message-bundle> in faces-config.xml and define the keys above to replace the default texts everywhere. The file below made the failed post print Page count: "y" is not a whole number. Type digits only, for example 9346.:

jakarta.faces.converter.IntegerConverter.INTEGER={2}: "{0}" is not a whole number.
jakarta.faces.converter.IntegerConverter.INTEGER_detail=Type digits only, for example {1}.
<application>
  <message-bundle>org.example.bookshelf.messages</message-bundle>
</application>

For a Spanish bundle, the name is the same with a locale suffix (messages_es.properties), and the messages and internationalisation page of this section shows English and Spanish files side by side. <message-bundle> is for overriding the messages of the Faces implementation; the bundle you read from the page with <resource-bundle> or var is a different thing.

A custom converter: the message in the exception. Build the FacesMessage yourself, as the converters above do, and read the text from a bundle if the application is translated. A ConverterException created with only a string gets that string as the message.

Conversion errors with Ajax

The review form is submitted with f:ajax (the Ajax page explains execute and render). The lifecycle is the same, except that only the components named in execute are decoded and converted, and the messages reach the page only if a message component is in render. With a h:message and h:messages inside the form, the form below re-renders both on every submit. A raw partial request for rating=abc returned the update element <span id="f:m">Type digits only, for example 9346.</span> for the h:message and a ul with the same text for h:messages. A request that listed only @form in the hand-built jakarta.faces.partial.execute did not convert the field, so test with a real browser or a test client that runs faces.js, not with hand-made requests.

The Bookshelf Review Form

This is the form the rest of the section builds on: the converters from this page combined with the template of the templating page. The bean is request-scoped to keep the example short; the scopes page explains why a real Ajax form uses a view-scoped bean.

@Named("review")
@RequestScoped
public class ReviewFormBean {

    @Inject
    private AuthorService authorService;

    private Isbn isbn;
    private Author author;
    private Genre genre;
    private Integer rating;
    private LocalDate readOn;
    private BigDecimal paidPrice;
    private List<String> tags = List.of();
    private String summary;

    public List<Author> getAuthors() {
        return authorService.findAll();
    }

    public Genre[] getGenres() {
        return Genre.values();
    }

    public String submit() {
        // In the real application the review is stored here.
        summary = "Saved review of " + isbn.formatted() + " by " + author.name() + ", rating " + rating
                + ", read on " + readOn + ", paid " + paidPrice + ", tags " + tags + ", genre " + genre;
        return null;
    }

    // getters and setters for isbn, author, genre, rating, readOn, paidPrice, tags and summary
}
<ui:composition template="/WEB-INF/templates/layout.xhtml"
                xmlns:h="jakarta.faces.html"
                xmlns:f="jakarta.faces.core"
                xmlns:ui="jakarta.faces.facelets">
  <ui:define name="title">New review</ui:define>
  <ui:define name="content">
    <h:form id="reviewForm">
      <h:messages id="messages" globalOnly="true" showDetail="true"/>

      <h:outputLabel for="isbn" value="ISBN"/>
      <h:inputText id="isbn" label="ISBN" value="#{review.isbn}" required="true"/>
      <h:message for="isbn"/>

      <h:outputLabel for="author" value="Author"/>
      <h:selectOneMenu id="author" label="Author" value="#{review.author}" converter="bookshelf.author" required="true">
        <f:selectItem itemLabel="Choose an author" noSelectionOption="true"/>
        <f:selectItems value="#{review.authors}" var="a" itemValue="#{a}" itemLabel="#{a.name}"/>
      </h:selectOneMenu>
      <h:message for="author"/>

      <h:outputLabel for="genre" value="Genre"/>
      <h:selectOneMenu id="genre" value="#{review.genre}">
        <f:selectItem itemLabel="Any" noSelectionOption="true"/>
        <f:selectItems value="#{review.genres}"/>
      </h:selectOneMenu>

      <h:outputLabel for="rating" value="Rating (1 to 5)"/>
      <h:inputText id="rating" label="Rating" value="#{review.rating}"
                   converterMessage="The rating must be a whole number."/>
      <h:message for="rating"/>

      <h:outputLabel for="readOn" value="Read on (yyyy-MM-dd)"/>
      <h:inputText id="readOn" label="Date read" value="#{review.readOn}">
        <f:convertDateTime type="localDate" pattern="yyyy-MM-dd"/>
      </h:inputText>
      <h:message for="readOn"/>

      <h:outputLabel for="price" value="Price paid"/>
      <h:inputText id="price" label="Price" value="#{review.paidPrice}">
        <f:convertNumber pattern="#,##0.00" locale="en"/>
      </h:inputText>
      <h:message for="price"/>

      <h:outputLabel for="tags" value="Tags (separated by semicolons)"/>
      <h:inputText id="tags" value="#{review.tags}">
        <f:converter converterId="bookshelf.tagList"/>
        <f:attribute name="separator" value=";"/>
      </h:inputText>

      <h:commandButton id="save" value="Save review" action="#{review.submit}">
        <f:ajax execute="@form" render="@form"/>
      </h:commandButton>
      <h:outputText id="result" value="#{review.summary}"/>
    </h:form>
  </ui:define>
</ui:composition>

The form was loaded in a real browser (the Ajax script ran) and submitted twice. The first, invalid submission (12345 in ISBN, no author, x as rating, 07/10/2026 as date, abc as price) re-rendered the form with:

  • ISBN: '12345' must contain exactly 13 digits (hyphens are allowed).

  • Author: Author: Validation Error: Value is required. (the "Choose an author" option is a no-selection option whose value is the empty string, so required fires)

  • Rating: The rating must be a whole number.

  • Date read: Date read: '07/10/2026' could not be understood as a date. Example: 2026-10-08

  • Price: Price: 'abc' is not a number pattern. Example: #,##0.0#

and the second, valid one (978-0-306-40615-7, author 2, rating 5, 2026-10-07, 1,234.50, tags sci-fi; classics) ended with "Saved review of 978-0-306-40615-7 by Italo Calvino, rating 5, read on 2026-10-07, paid 1234.50, tags [sci-fi, classics], genre null". The messages for the numeric-pattern failure show the pattern as the "example", which can confuse readers; set converterMessage on fields whose pattern is not obvious.

Everything that touches security in this form is on the server (see the security page for output escaping): the author id and the ISBN are untrusted input that the converters parse and check, the page never prints a user-supplied value with escape="false", and the action only runs when conversion and validation succeed.

Converters in Other Places

  • h:outputText. It is a UIOutput and uses the same lookup, so f:convertNumber and f:convertDateTime are the way to format numbers and dates for display. Do not build the text in a getter that returns a pre-formatted string, which defeats the locale handling.

  • f:viewParam. A view parameter has the attributes converter and converterMessage (VDL documentation), so a bookmarkable ?isbn=... URL can be turned into an Isbn; the view parameters page covers it.

  • Composite components. f:convertNumber, f:convertDateTime and f:converter have a for attribute that attaches the converter to a named target of the composite component, not to the composite itself (composite components).

  • Programmatic access. FacesContext.getCurrentInstance().getApplication().createConverter(Isbn.class) and createConverter("bookshelf.author") return a configured instance by type or by id, which is how code outside the view (an exception handler, a REST endpoint) can reuse the same parsing as the form. A UI component accepts a converter from setConverter().

  • Client behaviours and Ajax events. A valueChange event of an input is delivered only after conversion and validation succeeded; a field with a conversion error never fires its listener (the events page covers listeners).

  • Validators. They receive the converted value. A validator on Isbn therefore checks a checksum, not text, and the page on validators shows f:validateRegex for fields kept as strings.

Mojarra and MyFaces

The two implementations pass the same specification tests, and for converters they agree on the contract. The differences below were all observed by running the same files on Mojarra 4.1.17 and MyFaces 4.1.4, and they are the reason an application should be tested on the implementation it deploys:

Situation Mojarra 4.1.17 MyFaces 4.1.4

LocalDate property with no converter

Field message "Conversion Error setting value …​ for 'null Converter'"

Unhandled ELException ("Cannot convert [..] of type [class java.lang.String] to [class java.time.LocalDate]")

Number with trailing text, 12abc

Accepted as 12

Rejected: "is not a number"

1.234,5 in the en locale

Accepted as 1.234

Rejected

Managed converter without a bean-defining annotation

Clear error text that mentions beans.xml

NullPointerException at render time

Default messages for the same errors

Same keys

Same keys, different example values (Example: 9346 against Example: 21)

Behaviour the two shared in the runs: the formatted output of every f:convertNumber and f:convertDateTime row above, converterMessage, a managed converter with @Dependent used by id on a menu, the custom ISBN, tag list and author converters, and a converter that is a CDI bean attached with an EL expression. The GMT default, the label text and the by-type menu lookup were run on Mojarra only.

What Changed Between Versions

Version Change relevant to converters

JSF 1.2

EnumConverter; converters that take a Class in their constructor

JSF 2.0

@FacesConverter as the normal registration instead of the faces-config.xml entry (the API documentation describes the annotation as taking the place of the XML entries)

JSF 2.3

java.time types in f:convertDateTime; Faces artefacts including converters become injectable through CDI, with managed = true marking a CDI-managed converter

Faces 3.0

javax.faces.convert.* becomes jakarta.faces.convert.* (the package rename listed on the version page)

Faces 4.0

The namespaces become jakarta.faces.html, jakarta.faces.core and jakarta.faces.facelets; no converter feature was found in the change log

Faces 4.1

UUIDConverter (jakarta.faces.UUID); the 4.1 API documents Converter<T> as generic

Faces 5.0 (upcoming, milestone 5.0.0-M9)

The planned work lists generics for the standard converters and an EnumConverter that reads the enum type from the value expression; not described here as current behaviour

The 2.3 row follows the section’s version page; the 2.0 row and the point at which Converter became generic were not independently checked.

Where the Books Are Outdated

Conversion is covered in nearly every Faces book: the converter chapters of JavaServer Faces 2.0: The Complete Reference (Burns and Schalk), the converter recipes of the JSF 2.0 Cookbook (Leonard, including converters with parameters and converters bound to beans), the conversion and validation chapters of Mann’s JavaServer Faces in Action and of Dudney et al. The central idea (a converter maps between the string in the page and the object in the model, in both directions, in two lifecycle phases) has not changed since the first edition. These points have:

  • Packages and ids. The books use javax.faces.convert.Converter. The current interface is jakarta.faces.convert.Converter, the current ids start with jakarta.faces. (jakarta.faces.Integer), and the namespace of the tags is jakarta.faces.core (the JSP <%@ taglib uri="http://java.sun.com/jsf/core" %> of the early books is gone).

  • Registration in XML. In the JSF 1.x books a custom converter is registered in faces-config.xml and needs the <converter-for-class> or <converter-id> element. The annotation is the normal way now, and the XML remains valid.

  • No java.time. The books convert java.util.Date and SimpleDateFormat. Modern code keeps LocalDate and friends and uses f:convertDateTime type="localDate"; the Date path keeps its GMT default.

  • "Converter with a parameter" recipes. Leonard and others use a managed bean, @ManagedProperty or a lookup from FacesContext to get a service into a converter. Today that is @Inject in a managed = true converter, and a service lookup by ELContext is never needed.

  • Entity converters. The older books often convert an entity by serialising it in getAsString() or by calling a static DAO. Convert to the key and look the entity up through an injected service, as shown above.

  • Ajax. They describe a hand-written refresh for a failed conversion; with f:ajax the messages are components in render.

  • No UUIDConverter, which is new in 4.1.

Common Pitfalls

  • A LocalDate or Date field with no converter. It fails in the model update (Mojarra) or with an unhandled ELException (MyFaces). Always attach f:convertDateTime or a converter.

  • A String property that is "" instead of null. Set jakarta.faces.INTERPRET_EMPTY_STRING_SUBMITTED_VALUES_AS_NULL or test for blank.

  • Lenient number parsing in Mojarra. 12abc becomes 12. Validate, or use a strict custom converter.

  • The GMT default for java.util.Date. Set timeZone or the system-timezone context parameter, or use java.time.

  • The narrow no-break space in localized times. Do not ask users to type a style format; give them a pattern.

  • Throwing anything but ConverterException. Wrap parsing errors.

  • Forgetting @Dependent on a managed converter when CDI uses annotated discovery.

  • Expecting @Inject in an ordinary @FacesConverter. It stays null.

  • Trusting the id of an entity converter. Check that the user may choose that entity.

  • A converter with per-request fields registered as @ApplicationScoped. It is shared by all threads.

  • converter="some.package.Class". Use the converter id; the class name failed on both implementations.

  • No label. Messages show the client id (reviewForm:rating) and not a human name.

  • #{...} in page text. It does not use a converter; use h:outputText for formatted output.