Map Java Enums to Custom Database Values with Quarkus and Hibernate Panache
A hands-on guide for Java developers to master enum-to-column mapping using converters, @EnumeratedValue, Panache entities, and PostgreSQL.
Modern enterprise systems often have to integrate with legacy databases that store coded values instead of textual names. A classic example: status columns that use numeric codes like 100 for “Pending” or 10 for “Approved.”
Mapping such enums cleanly in Java without hardcoding logic or sacrificing type safety is a recurring problem. Fortunately, Quarkus and Hibernate ORM with Panache make this task both elegant and maintainable.
In this tutorial, I’ll walk through how to map a Java enum to custom database values in a Quarkus application.
Prerequisites
You’ll need:
Java 17+
Maven 3.9+
Podman or Docker for the PostgresQL Dev Service
Quarkus 3.28 or newer
Create a new project or grab the example from my Github repository!
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=enum-mapping-demo \
-Dextensions="hibernate-orm-panache, jdbc-postgresql, rest-jackson"
cd enum-mapping-demoThen open the project in your IDE.
Database Schema
For this example, we’ll use a simple post entity. Hibernate will generate the schema automatically at start from your Entity, if you enable drop-and-create in configuration. Let’s do that right now in the application.properties
# Schema generation
quarkus.hibernate-orm.schema-management.strategy = drop-and-create
# Logging
quarkus.hibernate-orm.log.sql=true
quarkus.hibernate-orm.log.bind-parameters=trueWe’ll also add logging and parameter binding, so we can see what is happening in the console log later.
Defining the Enum
We’ll define PostStatus with explicit codes that don’t match the enum’s ordinal values.
package com.example.model;
public enum PostStatus {
PENDING(100),
APPROVED(10),
SPAM(50),
REQUIRES_MODERATOR_INTERVENTION(1);
private final int statusCode;
PostStatus(int statusCode) {
this.statusCode = statusCode;
}
public int getStatusCode() {
return statusCode;
}
public static PostStatus fromStatusCode(int statusCode) {
for (PostStatus status : values()) {
if (status.statusCode == statusCode) {
return status;
}
}
throw new IllegalArgumentException(”Unknown status code: “ + statusCode);
}
}This pattern lets you decouple database representations from internal enum order, making refactoring safe and schema migrations unnecessary.
Creating a Generic Converter
Instead of writing a converter for every enum, we can generalize the logic.
package com.example.converter;
import java.util.HashMap;
import java.util.Map;
import jakarta.persistence.AttributeConverter;
public abstract class CustomOrdinalEnumConverter<T extends Enum<T>>
implements AttributeConverter<T, Integer> {
private final Map<Integer, T> codeToEnum = new HashMap<>();
private final Class<T> enumType;
protected CustomOrdinalEnumConverter(Class<T> enumType) {
this.enumType = enumType;
for (T value : enumType.getEnumConstants()) {
Integer dbValue = convertToDatabaseColumn(value);
codeToEnum.put(dbValue, value);
}
}
@Override
public T convertToEntityAttribute(Integer dbValue) {
if (dbValue == null)
return null;
T enumValue = codeToEnum.get(dbValue);
if (enumValue == null) {
throw new IllegalArgumentException(
“Unknown “ + enumType.getSimpleName() + “ code: “ + dbValue);
}
return enumValue;
}
}This base class handles reverse mapping once you define the conversion logic in a subclass.
Implementing the Converter for PostStatus
package com.example.converter;
import com.example.model.PostStatus;
import jakarta.persistence.Converter;
@Converter(autoApply = false)
public class PostStatusConverter extends CustomOrdinalEnumConverter<PostStatus> {
public PostStatusConverter() {
super(PostStatus.class);
}
@Override
public Integer convertToDatabaseColumn(PostStatus status) {
return status != null ? status.getStatusCode() : null;
}
}@Converter(autoApply = false) means we’ll manually specify which fields use it.
The Panache Entity
Create a Post entity with a mapped enum column:
package com.example.entity;
import com.example.converter.PostStatusConverter;
import com.example.model.PostStatus;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = “post”)
public class Post extends PanacheEntity {
@Column(length = 250)
public String title;
@Column(columnDefinition = “NUMERIC(3)”)
@Convert(converter = PostStatusConverter.class)
public PostStatus status;
}Using the Panache Active Record pattern lets you persist entities directly via .persist().
Creating a Repository (Optional)
package com.example.repository;
import java.util.List;
import com.example.entity.Post;
import com.example.model.PostStatus;
import io.quarkus.hibernate.orm.panache.PanacheRepository;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class PostRepository implements PanacheRepository<Post> {
public List<Post> findByStatus(PostStatus status) {
return list(”status”, status);
}
public List<Post> findPendingPosts() {
return findByStatus(PostStatus.PENDING);
}
}Repositories are optional in Panache, but they’re useful when you prefer separation of concerns.
Building a REST Resource
Rename the scaffolded GreetingResource to PostResource and replace with:
package com.example;
import java.util.List;
import com.example.entity.Post;
import com.example.model.PostStatus;
import com.example.repository.PostRepository;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/posts”)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PostResource {
@Inject
PostRepository postRepository;
@POST
@Transactional
public Response create(Post post) {
post.persist();
return Response.status(Response.Status.CREATED).entity(post).build();
}
@GET
public List<Post> listAll() {
return Post.listAll();
}
@GET
@Path(”/{id}”)
public Post getById(@PathParam(”id”) Integer id) {
return postRepository.findByIdOptional(id.longValue())
.orElseThrow(() -> new WebApplicationException(”Post not found”, 404));
}
@GET
@Path(”/status/{status}”)
public List<Post> findByStatus(@PathParam(”status”) PostStatus status) {
return postRepository.findByStatus(status);
}
}Run your app in dev mode and test with:
./mvnw quarkus:devThen post new entries using curl or HTTPie:
curl -X POST http://localhost:8080/posts \
-H “Content-Type: application/json” \
-d ‘{”title”:”My First Post”,”status”:”APPROVED”}’Verifying the Mapping
Since we enabled SQL logging and schema generation in application.properties, we can now see the binding values in the console log:
[Hibernate]
select
nextval(’post_SEQ’)
[Hibernate]
insert
into
post
(status, title, id)
values
(?, ?, ?)
2025-11-12 08:39:58,639 TRACE [org.hib.orm.jdb.bind] (executor-thread-1) binding parameter (1:INTEGER) <- [10]
2025-11-12 08:39:58,639 TRACE [org.hib.orm.jdb.bind] (executor-thread-1) binding parameter (2:VARCHAR) <- [My First Post]
2025-11-12 08:39:58,639 TRACE [org.hib.orm.jdb.bind] (executor-thread-1) binding parameter (3:BIGINT) <- [1]The key detail: Hibernate uses your enum’s custom status code rather than the ordinal index.
Testing It
Let’s confirm the mapping works correctly. And yes, you now see me creating another test for one of my projects. Looks like I am getting used to it ;)
package com.example;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import com.example.entity.Post;
import com.example.model.PostStatus;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import jakarta.persistence.EntityManager;
import jakarta.transaction.Transactional;
@QuarkusTest
public class PostStatusConverterTest {
@Inject
EntityManager entityManager;
@Test
@Transactional
void shouldMapCustomEnumValuesCorrectly() {
Post post = new Post();
post.title = “Pending Approval”;
post.status = PostStatus.PENDING;
post.persist();
entityManager.flush();
entityManager.clear();
Post reloaded = Post.findById(1);
assertEquals(PostStatus.PENDING, reloaded.status);
}
}Run:
./mvnw testEvolving the Mapping: @EnumeratedValue in Hibernate 7 and Quarkus 3
So far, we’ve implemented custom enum mapping using a reusable converter. That pattern works in all environments and gives full control over how enum values are stored.
Starting with Jakarta EE 11 and Jakarta Persistence 3.2, Quarkus developers can now simplify this pattern using the new @EnumeratedValue annotation. It lets you mark a field inside your enum as the storage value, and Hibernate will automatically use it instead of ordinal() or name().
Let’s extend our existing example to see what changes.
Simplify the Enum
You can reuse the same PostStatus enum, but now we annotate the internal statusCode field with @EnumeratedValue:
package com.example.model;
import jakarta.persistence.EnumeratedValue;
public enum PostStatus {
PENDING(100),
APPROVED(10),
SPAM(50),
REQUIRES_MODERATOR_INTERVENTION(1);
@EnumeratedValue
private final int statusCode;
PostStatus(int statusCode) {
this.statusCode = statusCode;
}
public int getStatusCode() {
return statusCode;
}
}This one line effectively replaces the entire converter class. Hibernate now knows that statusCode is the persistent representation for this enum.
I have renamed both classes in the coverter package so they do not interfere.
Update the Entity
In the existing Post entity, we no longer need the @Convert annotation. Replace it with the standard @Enumerated:
package com.example.entity;
import com.example.model.PostStatus;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
@Entity
@Table(name = “post”)
public class Post extends PanacheEntity {
@Column(length = 250)
public String title;
@Column(columnDefinition = “NUMERIC(3)”)
@Enumerated(EnumType.ORDINAL)
public PostStatus status;
public Post setId(Long id) {
this.id = id;
return this;
}
public Post setTitle(String title) {
this.title = title;
return this;
}
public Post setStatus(PostStatus status) {
this.status = status;
return this;
}
}When Hibernate detects that PostStatus contains an @EnumeratedValue, it automatically uses that field (statusCode) for reading and writing database values.
No converter, no boilerplate — just a clean, declarative mapping.
Verify Behavior
Restart the application in dev mode:
./mvnw quarkus:devInsert a few posts again using the same REST endpoints:
curl -X POST http://localhost:8080/posts \
-H “Content-Type: application/json” \
-d ‘{”title”:”My First Post”,”status”:”PENDING”}’In the console logs, you’ll now see the same INSERT statements as before:
[Hibernate]
select
nextval(’post_SEQ’)
[Hibernate]
insert
into
post
(status, title, id)
values
(?, ?, ?)
2025-11-12 13:08:13,131 TRACE [org.hib.orm.jdb.bind] (executor-thread-1) binding parameter (1:SMALLINT) <- [100]
2025-11-12 13:08:13,131 TRACE [org.hib.orm.jdb.bind] (executor-thread-1) binding parameter (2:VARCHAR) <- [My First Post]
2025-11-12 13:08:13,131 TRACE [org.hib.orm.jdb.bind] (executor-thread-1) binding parameter (3:BIGINT) <- [1]Hibernate wrote the statusCode value automatically. The result is the same as with the converter but achieved with far less code (two less classes in this case).
Considerations Before Adopting
@EnumeratedValuerequires Jakarta Persistence 3.2If your application runs in a mixed environment (older microservices, legacy JPA providers), stick with the converter for now.
Always check that the database column type (
SMALLINT,INTEGER) matches the annotated field type.
Why This Matters
The @EnumeratedValue annotation is small but powerful. It makes your enum self-contained, easier to read, and safer to maintain.
Instead of scattering mapping logic across converters, you define it once, right where the enum values live.
It’s not a replacement for converters in every case — but for straightforward numeric or string mappings, it’s the new default way forward in modern Quarkus projects.
Summary
You’ve built a complete Quarkus application that:
Maps enums to custom database values in two ways
Uses Hibernate ORM with Panache.
Supports clean conversion logic reusable across entities.
Works transparently with REST endpoints and PostgreSQL.
This pattern is production-ready and works perfectly in native builds.
Enums are small, but getting them right saves you big headaches later.



