Durante años, crear las tablas de una aplicación con Hibernate desde código significaba tirar de SchemaExport, de hibernate.hbm2ddl.auto o de un montaje propio con MetadataSources y ServiceRegistry. Con Jakarta Persistence 3.2 eso tiene ya una vía estándar: la interfaz jakarta.persistence.SchemaManager, que se obtiene directamente del EntityManagerFactory. Hibernate 7 la implementa y la amplía con operaciones propias. La serie estable actual es la 7.4, con la 7.4.14.Final publicada el 25 de octubre de 2026, que exige Java 17 como mínimo y funciona hasta Java 26.
Este artículo repasa qué ofrece SchemaManager, cómo se compara con las propiedades declarativas de siempre, para qué sirve de verdad (spoiler: sobre todo para tests y para generar scripts) y por qué en producción el esquema debería moverlo Flyway o Liquibase y no Hibernate.
Qué ha cambiado respecto a SchemaExport y hbm2ddl
En Hibernate 5 la clase org.hibernate.tool.hbm2ddl.SchemaExport era la forma habitual de generar el DDL por código. Si abres hoy los JAR de hibernate-core 6.6.59 y 7.4.14 comprobarás que el paquete org.hibernate.tool.hbm2ddl ya no está en ninguno de los dos. Los ejemplos antiguos que montaban un StandardServiceRegistry, construían un Metadata y llamaban a new SchemaExport().create(...) dejan de compilar al migrar.
La sustitución llegó en dos pasos. Hibernate 6.2 añadió su propia interfaz org.hibernate.relational.SchemaManager, marcada como @Incubating, para exportar, validar, limpiar y poblar el esquema «como comodidad para escribir tests». Después, Jakarta Persistence 3.2 incorporó jakarta.persistence.SchemaManager con cuatro operaciones. La interfaz de Hibernate pasó a extender la estándar y se conservó por compatibilidad y para alojar operaciones que el estándar no define.
La de Jakarta es mínima:
Operación | Qué hace |
| Crea los objetos de base de datos mapeados por las entidades. Con |
| Borra esos objetos, deshaciendo la creación previa. |
| Comprueba que la base de datos tiene lo que el mapeo espera. Lanza |
| Vacía las tablas mapeadas y vuelve a importar los datos iniciales de los scripts de carga configurados. |
La propia especificación avisa de dos cosas: si una sentencia DDL falla, el comportamiento queda sin definir (el proveedor puede lanzar una excepción o seguir adelante), y el proveedor no está obligado a ningún tipo concreto de validación, así que el alcance de validate() depende de la implementación.
La vía declarativa: propiedades de generación de esquema
Antes de ver la API conviene tener claro lo que ya hacías con propiedades, porque SchemaManager convive con ellas. Hay dos propiedades equivalentes que ordenan una acción contra la base de datos al arrancar la SessionFactory:
jakarta.persistence.schema-generation.database.action, la estándar.hibernate.hbm2ddl.auto, la clásica de Hibernate.
Si se indican las dos, manda la de Jakarta. Si no se indica ninguna, el valor por defecto es none: Hibernate no toca el esquema. Los valores posibles los enumera org.hibernate.tool.schema.Action: none, create-only, drop, create, create-drop, validate, update y populate, más synchronize para alinear las secuencias con los datos de las tablas. El enum incluye además una acción TRUNCATE. El estándar JPA añade drop-and-create.
Ojo con create, que significa cosas distintas según la propiedad. La documentación de Action lo advierte: en la configuración clásica de Hibernate create borra el esquema antes de recrearlo, mientras que en el estándar JPA equivale a create-only y no borra nada. Si vienes de un hbm2ddl.auto=create y migras a la propiedad de Jakarta, usa drop-and-create para conservar el comportamiento.
Hay un detalle más: el fichero /import.sql (o los que indiques en hibernate.hbm2ddl.import_files) solo se ejecuta cuando es Hibernate quien crea el esquema, es decir, con create o create-drop.
SchemaManager en la práctica
Para el ejemplo usamos una entidad pequeña y una base de datos H2 en memoria. Estas son las dependencias de Maven; añade el driver de H2 con una versión reciente:
<dependencies>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>7.4.14.Final</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
La entidad:
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "libro")
public class Libro {
@Id
@GeneratedValue
private Long id;
@Column(nullable = false, unique = true, length = 13)
private String isbn;
@Column(nullable = false)
private String titulo;
protected Libro() { }
public Libro(String isbn, String titulo) {
this.isbn = isbn;
this.titulo = titulo;
}
public String getIsbn() { return isbn; }
public String getTitulo() { return titulo; }
}
Y un programa que recorre el ciclo completo. Jakarta Persistence 3.2 trae PersistenceConfiguration, que permite arrancar sin persistence.xml, y constantes para las propiedades estándar:
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceConfiguration;
import jakarta.persistence.SchemaManager;
import jakarta.persistence.SchemaValidationException;
public class EsquemaDemo {
public static void main(String[] args) {
var config = new PersistenceConfiguration("demo")
.managedClass(Libro.class)
.property(PersistenceConfiguration.JDBC_URL,
"jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "");
try (EntityManagerFactory emf = config.createEntityManagerFactory()) {
SchemaManager esquema = emf.getSchemaManager();
// 1. La base de datos está vacía: la validación debe fallar
try {
esquema.validate();
} catch (SchemaValidationException e) {
System.out.println("Esquema ausente: " + e.getMessage());
}
// 2. Crear las tablas (false: no tocar schemas)
esquema.create(false);
esquema.validate(); // ahora pasa
// 3. Insertar datos
emf.runInTransaction(em ->
em.persist(new Libro("9788441546073", "Java moderno")));
// 4. Vaciar tablas y reiniciar secuencias
esquema.truncate();
// 5. Borrar todo
esquema.drop(false);
}
}
}
Como EntityManagerFactory es AutoCloseable desde Jakarta Persistence 3.1, el try-with-resources cierra la factoría por ti. Esa factoría, creada por Hibernate, es en realidad una SessionFactory, y si necesitas lo específico de Hibernate la obtienes con emf.unwrap(SessionFactory.class).
Lo que añade Hibernate sobre el estándar
org.hibernate.relational.SchemaManager mantiene los equivalentes con nombres propios (exportMappedObjects, dropMappedObjects, validateMappedObjects y truncateMappedObjects) y suma operaciones que el estándar no cubre. Toda la interfaz sigue marcada como incubating, así que sus firmas pueden cambiar entre versiones menores:
Operación | Desde | Para qué sirve |
| 7.0 | Ejecuta |
| 7.1 | Devuelve un gestor que apunta a un schema o catálogo concreto, útil con multitenencia por schema. |
| 7.2 | Vacía una sola tabla y reinicia su secuencia o tabla generadora asociada. No repuebla. |
| 7.2 | Reajusta secuencias y generadores basados en tabla para que las claves nuevas superen a las existentes. |
resynchronizeGenerators() es la que más problemas evita. Si cargas filas con identificadores explícitos mediante SQL o un volcado, las secuencias de Hibernate no se enteran, y el primer persist posterior choca con una clave duplicada. Llamarla tras la carga lo resuelve.
Una precisión de truncate() y truncateMappedObjects(): vacían tablas, reinician secuencias y reimportan los datos iniciales, pero no limpian la caché de segundo nivel. Si la tienes activa en tus tests, haz un emf.getCache().evictAll() aparte.
Para usar estas operaciones se pasa por unwrap:
import org.hibernate.SessionFactory;
SessionFactory sf = emf.unwrap(SessionFactory.class);
org.hibernate.relational.SchemaManager esquema = sf.getSchemaManager();
esquema.truncateTable("libro");
esquema.populate();
esquema.resynchronizeGenerators();
Su sitio natural: los tests de integración
El propio javadoc de Hibernate lo dice: la interfaz existe «como comodidad para escribir tests». El patrón habitual es crear el esquema una vez por clase de test y vaciar las tablas antes de cada método, en lugar de recrear la base de datos cada vez, que es mucho más lento:
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceConfiguration;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class LibroPersistenciaTest {
static EntityManagerFactory emf;
@BeforeAll
static void arrancar() {
emf = new PersistenceConfiguration("test")
.managedClass(Libro.class)
.property(PersistenceConfiguration.JDBC_URL,
"jdbc:h2:mem:test;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.createEntityManagerFactory();
emf.getSchemaManager().create(false);
}
@BeforeEach
void limpiar() {
emf.getSchemaManager().truncate();
}
@AfterAll
static void cerrar() {
emf.getSchemaManager().drop(false);
emf.close();
}
@Test
void guardaUnLibro() {
emf.runInTransaction(em ->
em.persist(new Libro("9788441546073", "Java moderno")));
long total = emf.callInTransaction(em ->
em.createQuery("select count(l) from Libro l", Long.class)
.getSingleResult());
assertEquals(1L, total);
}
}
Con Testcontainers el esquema también puede crearse así contra PostgreSQL o MySQL reales, pero entonces conviene probar el esquema de verdad, el de las migraciones, como se explica más abajo.
Generar el DDL a un fichero sin tocar la base de datos
Otro uso legítimo es sacar el script de creación para revisarlo o para usarlo de punto de partida de una migración. Para eso están las propiedades de scripts, que tienen constantes en PersistenceConfiguration:
var config = new PersistenceConfiguration("ddl")
.managedClass(Libro.class)
.property(PersistenceConfiguration.JDBC_URL,
"jdbc:h2:mem:ddl;DB_CLOSE_DELAY=-1")
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.property(PersistenceConfiguration.SCHEMAGEN_SCRIPTS_ACTION, "create")
.property(PersistenceConfiguration.SCHEMAGEN_CREATE_TARGET,
"target/schema-create.sql");
config.createEntityManagerFactory().close();
Al arrancar la factoría, Hibernate escribe el DDL de creación en target/schema-create.sql sin ejecutarlo contra la base de datos (la acción de base de datos sigue en none). El dialecto sale del motor al que se conecta, de modo que si el script va a ser para PostgreSQL, genera el fichero contra un PostgreSQL y no contra H2: los tipos y las secuencias cambian de un motor a otro. Es el equivalente moderno de lo que hacía SchemaExport.setOutputFile().
Por qué no en producción
Todo lo anterior es cómodo, y precisamente por eso conviene ponerle límites. Las acciones create, create-drop y drop son destructivas por definición. update parece la opción prudente, pero es la que más sorpresas da:
- Solo añade. No elimina columnas ni tablas que ya no están en el mapeo, así que el esquema real se aleja poco a poco del modelo.
- No entiende de renombrados. Cambiar
titulopornombrecrea una columna nueva y deja la antigua con los datos dentro. - No migra datos. Pasar una columna de
varcharainteger, partir un campo en dos o rellenar un valor por defecto en filas existentes requiere SQL escrito a mano. - No hay revisión ni historial. Nadie ve el DDL antes de que se ejecute, y no puedes saber en qué estado exacto está cada entorno.
- Con varias instancias arrancando a la vez, todas intentan alterar el esquema al mismo tiempo.
La alternativa es tratar el esquema como código versionado con una herramienta de migraciones: Flyway (scripts SQL numerados) o Liquibase (changelogs en XML, YAML o SQL). El DDL queda en el repositorio, se revisa en las pull requests, se aplica en el mismo orden en todos los entornos y deja registro de lo ejecutado. Hibernate pasa a ser consumidor del esquema, no su dueño.
Dos acciones sí tienen sentido fuera de los tests. La primera es none, el valor por defecto. La segunda es validate, que hace que la aplicación se niegue a arrancar si el esquema no coincide con el mapeo. Es un seguro barato contra el despliegue en el que alguien olvidó incluir una migración.
El flujo recomendado: migraciones más validate
Un esquema inicial puede nacer del DDL que generaste en el apartado anterior. Lo revisas, lo retocas (índices, tipos concretos, nombres de restricciones) y lo guardas como primera migración de Flyway, en src/main/resources/db/migration/V1__crear_libro.sql:
create sequence libro_seq start with 1 increment by 50;
create table libro (
id bigint not null,
isbn varchar(13) not null,
titulo varchar(255) not null,
primary key (id),
constraint uk_libro_isbn unique (isbn)
);
El increment by 50 de la secuencia coincide con el tamaño de asignación por defecto de Hibernate para @GeneratedValue con secuencias; si lo cambias en el mapeo, cámbialo aquí también, porque es justo el tipo de desajuste que validate te va a señalar. Desde Flyway 10 el soporte de cada motor viaja en módulos independientes, de modo que con PostgreSQL necesitas flyway-core y también flyway-database-postgresql.
En Spring Boot el arranque queda así, y el artículo sobre migraciones de base de datos versionadas con Flyway en Spring Boot lo desarrolla con más detalle:
spring.jpa.hibernate.ddl-auto=validate spring.flyway.enabled=true
La pieza que cierra el círculo es un test que ejecuta las migraciones sobre una base de datos vacía y luego pide a Hibernate que valide su mapeo contra el resultado. Si alguien cambia una entidad y no escribe la migración, el test falla en CI, no en producción:
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.PersistenceConfiguration;
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
class EsquemaCoincideConMigracionesTest {
static final String URL = "jdbc:h2:mem:migraciones;DB_CLOSE_DELAY=-1";
@Test
void elMapeoCoincideConLasMigraciones() {
Flyway.configure()
.dataSource(URL, "sa", "")
.load()
.migrate();
try (EntityManagerFactory emf = new PersistenceConfiguration("check")
.managedClass(Libro.class)
.property(PersistenceConfiguration.JDBC_URL, URL)
.property(PersistenceConfiguration.JDBC_USER, "sa")
.property(PersistenceConfiguration.JDBC_PASSWORD, "")
.createEntityManagerFactory()) {
// lanza SchemaValidationException si algo no cuadra
emf.getSchemaManager().validate();
}
}
}
Si usas Liquibase, el esquema de este test es idéntico: cambia el bloque de Flyway por la ejecución del changelog. Y para un motor real, sustituye H2 por un contenedor de Testcontainers del mismo motor y versión que producción.
Equivalencias rápidas al migrar desde SchemaExport
Antes | Ahora |
|
|
|
|
|
|
Script a fichero con |
|
Limpiar tablas entre tests con SQL a mano |
|
|
|
Errores habituales
- Confiar en
validate()más de la cuenta. El estándar no fija qué se valida, y en la práctica Hibernate comprueba tablas, columnas, tipos y secuencias, pero no pretende ser una comparación completa de todo el esquema. Trátalo como red de seguridad, no como prueba formal. - Olvidar los schemas. Si tus entidades usan
@Table(schema = "..."),create(false)da error porque el schema no existe. Pasatrueo créalo antes. - Suponer que un fallo de DDL siempre lanza excepción. El estándar deja el comportamiento sin definir. Comprueba el resultado con
validate()después decreate. - Cargar datos con SQL y olvidar los generadores. Llama a
resynchronizeGenerators(), disponible desde Hibernate 7.2. - Dejar
create-dropoupdateheredados en un perfil de producción por un copia y pega de la configuración de desarrollo. Fuerzavalidateononeen ese perfil.
Cómo se reparten las tareas
Hibernate 7.4 y Jakarta Persistence 3.2 dan por fin una API estándar para gestionar el esquema por código: create, drop, validate y truncate desde el EntityManagerFactory, más extras de Hibernate como populate, truncateTable y resynchronizeGenerators. Es una herramienta excelente para tests y para generar scripts de partida. Para el esquema de producción, el reparto sensato es que mande Flyway o Liquibase, y que Hibernate se limite a validar en el arranque y en CI. Si tu proyecto sigue en Hibernate 6 y quieres repasar las bases del ORM, tienes Hibernate 6 y JPA 3.x en Java, y para el contexto del estándar, Jakarta EE 11 en 2026.
Imagen: Pexels / Brett Sayles
