There are a few different ways of storing Java enums in a database. This post explores Ordinal values, Enum names, Custom values, or Native enum types, each with its own use cases. It focuses on using the PostgreSQL native enum type. A native enum type is only needed in cases where the application does not have complete control over the data in the database. Specifically, the protection the enum type provides is only helpful in situations where another actor can modify the data. Such cases include migrations from previous databases, shared databases, and heavy reliance on manual modification.

TL;DR: Prefer EnumType.STRING for simplicity and safety. Use PostgreSQL native enums only when database-level enforcement is required and you can handle the extra maintenance.

Storing the enum

Storing the enum value in a database can be done in a few different ways:

  • Using an integer value representing the ordinal value
  • Using a text value representing the enum name
  • Using a custom value defined in the enum
  • Using a native enum type

Example enum:

public enum ExampleEnum {
	FOO,
	BAR
}

Ordinal value is the order in which the value appears in the list. In the ExampleEnum, the ordinal value of FOO is 0 and BAR is 1. A major downside is that if you add another value, it must be added at the end to maintain ordinal consistency. Similarly, if you delete a value, you may need to leave a placeholder to avoid shifting later values. An integer column is slightly smaller than an enum name. However, the Native enum value is even smaller. For these reasons, the Ordinal value is never recommended.

Enum name is the string value of the enum constant. In ExampleEnum, the enum name of FOO is "FOO", and BAR is "BAR". This is the simplest approach and should be preferred in most cases.

Custom value allows you to specify how each enum constant is stored. For example:

public enum ExampleEnum {
	FOO("FOO_CODE"),
	BAR("BAR_CUSTOM_VALUE");

	private final String storageValue;

	ExampleEnum(String storageValue) {
		this.storageValue = storageValue;
	}

	public String getStorageValue() {
		return storageValue;
	}
	
	public static ExampleEnum fromStorage(String value) {
		for (var e : values()) {
			if (e.storageValue.equals(value)) {
				return e;
			}
		}
		throw new IllegalArgumentException("Invalid storage value: " + value);
	}
}

Here, FOO is stored as FOO_CODE and BAR as BAR_CUSTOM_VALUE. This approach is useful when the storage value must differ from the enum name. It also supports integer codes when space must be minimized. In rare cases, you could even map multiple storage values to the same enum, though this is generally not recommended.

Native enum type uses the PostgreSQL enum type, which this post will explore. Internally, PostgreSQL stores enum values as OIDs, which are compact integers. While this may offer slightly more efficient storage than text, the performance difference is negligible in most applications. The main benefit of native enums is database-level validation of allowed values.

When to use the different types:

Storage method Use case
Ordinal value Rarely worth it
Enum name Preferred method for most applications
Custom value When enum values must map to something other than their names
Native enum type When your application’s database is shared with other actors

The rest of this article covers how to use the native enum type effectively. Use this approach only when you have a strong reason to. Otherwise, prefer storing the Enum name.

Why bother?

The main use cases are:

  • Migrations from previous databases
  • Shared databases
  • Heavy reliance on manual modification

Migrations from previous databases: During large migrations, enums help catch mismatches early. They provide feedback that certain values must be transformed to fit the new data model.

Shared databases: When multiple applications or actors share a database, it is crucial to have a clear contract for valid values. Enums enforce correctness at the type level, reducing debate about what data is allowed.

Heavy reliance on manual modification: Administrative tasks sometimes require manual SQL changes. Without constraints, these can easily introduce invalid data. Enum types ensure that manual edits still respect valid values.

In all these cases, enums push validation into the database layer. While I would not recommend spending time adding strict typing, constraints, and nullability rules in an application that fully owns its database, I would recommend them in these situations.

Using PostgreSQL enums

Setting up a PostgreSQL enum for the earlier ExampleEnum:

create type example_enum as enum ('FOO', 'BAR');

Enum values are case-sensitive, so casing matters.

Adding a new value:

alter type example_enum add value 'NEW_VALUE' after 'BAR';

Renaming a value:

alter type example_enum rename value 'NEW_VALUE' to 'TEST';

Deleting a value is not possible. Instead, you can adopt a convention: for example, prefix deleted values with DELETED:

alter type example_enum rename value 'TEST' to 'DELETED_TEST';

The enum type can then be used in a table:

create table example ( an_enum example_enum not null );

Integrating with Hibernate

The enum can then be used in Hibernate as follows:

@Entity
@Table(name = "example")
public class Example {
    @Enumerated(EnumType.STRING)
    @JdbcType(PostgreSQLEnumJdbcType.class)
    @Column(name = "an_enum")
    ExampleEnum anEnum;
}
  • @Enumerated(EnumType.STRING) tells Hibernate to pass the value as a string.
  • @JdbcType(PostgreSQLEnumJdbcType.class) ensures correct integration with PostgreSQL enums. This is a new addition in Hibernate 6. If you use earlier versions of Hibernate, much more custom code is required.
  • @Column(name = "an_enum") specifies the database column.
  • ExampleEnum is the Java enum (the database enum does not need to be written here).

Spanning multiple schemas

Most applications live in a single database schema, so this is rarely an issue. However, applications spanning multiple schemas, or backup solutions that write to a different schema, will run into problems. Enum types are schema-specific, so you cannot directly copy enum data between schemas.

This can be solved by defining enums in a shared schema, e.g., public:

create type public.example_enum as enum ('FOO', 'BAR');
create table example ( an_enum public.example_enum not null );

If you do this, it may be wise to separate enum migrations from the main schema migrations. That way, enums are created once, and schema migrations can run independently without affecting them. Extra caution is needed when renaming enums or values, as they may be used across multiple schemas, including backups. You do not want an enum migration to ruin your latest backup.

In Flyway, you could do this by having a migration folder specifically for the public schema, and the normal one for the other schemas. Then, set it up so the public schema version always runs first.

Synchronizing between Java and PostgreSQL

You can query PostgreSQL to list enums and their values:

select t.typname as enum_name, 
       e.enumlabel as enum_value
from pg_type t
join pg_enum e on t.oid = e.enumtypid
order by enum_name, e.enumsortorder;

Result:

enum_name enum_value
example_enum FOO
example_enum BAR

From this, you can:

  • Write a test that ensures enums in the database match those in code

    • Store all database enums in a central folder to make them easy to find
    • Or annotate/mark enums intended for database storage
  • Automatically generate SQL migration files to align the database with code

These tasks are project-specific and straightforward enough to be automated. For example, we used our AI tool to generate the test for it and a Gradle task for generating the SQL automatically.

Conclusion

PostgreSQL native enums provide one major benefit:

  • Database-level enforcement of valid enum values.

But they come with notable downsides:

  • You cannot delete enum values
  • Problems when working across schemas
  • Maintenance overhead of updating enums in two places

This post showed how to work with them but also highlighted that they should only be used in specific cases. Native enums proved valuable in a multi-year project with a thousand-hour migration effort, but for most applications, the downsides outweigh the benefits. I would only accept the complexities when they are truly needed.