The rename was three characters. A JPA field named userName became username. In the database, the column was user_name. The developer assumed Hibernate would handle it. The application had spring.jpa.hibernate.ddl-auto=update set in application.properties, the same file that goes to production. On the next deploy, Hibernate started up, scanned the entity, saw a field called username that did not match any existing column, and created a new column: username. The old user_name column stayed. Writes went to the new column. Reads from any query using the old column name returned null. Startup logs showed nothing wrong. Two days later, the bug surfaced as null usernames in reports.
This is the predictable failure mode of a setting that gets added during development and never removed.
What ddl-auto Actually Does
Hibernate's ddl-auto property controls what Hibernate does to the schema at application startup. In Spring Boot it is set as spring.jpa.hibernate.ddl-auto. There are five values:
- none: does nothing. The schema is your problem.
- validate: checks that the current schema matches the entity mappings. Fails to start if they diverge. Does not change anything.
- update: attempts to bring the schema in line with the entity mappings by adding missing tables and columns. Does not drop anything.
- create: drops all Hibernate-managed tables on startup, then recreates them. Destroys all data.
- create-drop: same as create, but drops everything again when the session factory closes. Used in test contexts.
Spring Boot's default is create-drop when an embedded database is detected (H2, HSQL, Derby) and none for everything else. The problem is not the default. The problem is that tutorials tell developers to set it to update to make development easier, and that setting lives in application.properties where it follows the application into every environment.
What update Does and Does Not Do
The documentation says update "updates the schema." The mental model this creates is wrong. update runs Hibernate's SchemaUpdate, which does the following:
- Creates tables that exist in your entity mappings but not in the database.
- Adds columns that exist in your entity mappings but not in the table.
- Does nothing else.
Dropping tables, dropping columns, renaming, changing column types, reordering constraints: none of that happens. When your entity diverges from the schema in any way that is not additive, update silently leaves the database in a state that does not match your code.
The rename case from the opening is the clearest example. But the same failure applies to any non-additive change: changing a column's length, dropping a field, changing a @Column(nullable = false) constraint on an existing column. Hibernate will not touch any of it.
The NOT NULL Column Problem
Adding a non-nullable column to an existing table is a migration that requires a default value or a backfill. Hibernate does not know this. If you add a field to your entity with @Column(nullable = false) and the table has existing rows, Hibernate will attempt ALTER TABLE ADD COLUMN column_name TYPE NOT NULL with no default. On PostgreSQL, that fails immediately if the table has any rows. Depending on your configuration, Hibernate either crashes on startup or logs the exception and continues with the schema in an inconsistent state.
If you are lucky, the failure is loud and the application does not start. If you are less lucky, Hibernate is configured to log and continue, the column does not exist, and your application starts serving requests against a schema that is missing a column it expects.
It Runs on Every Startup
Schema migrations should run once. They should be tracked. They should be reproducible. ddl-auto: update runs on every startup, against whatever state the database is in at that moment. There is no record of what ran. There is no way to roll back a change that Hibernate applied. There is no way to know whether two instances of the application started simultaneously and both attempted to apply the same DDL.
PostgreSQL's DDL is transactional, so concurrent column additions to the same table will not corrupt the schema. But two instances racing to apply schema changes on startup is not a migration process. It is a race condition that gets lucky most of the time.
Lucky, until it isn't.
There Is No Audit Trail
When something goes wrong with your schema, you want to know what changed, when, and which deploy caused it. A proper migration tool writes a record to a table (flyway_schema_history in Flyway, databasechangelog in Liquibase) every time it applies a migration. Query that table. Diff environments against it. You can see exactly which migrations have run against production and which have not.
With ddl-auto: update, the only record is the schema itself. If a column appeared that you did not expect, you have no way to know which deploy added it or whether it was intentional.
What to Use Instead
Flyway is the standard answer for Spring Boot applications. Add the dependency:
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>Create src/main/resources/db/migration/V1__initial_schema.sql and subsequent versioned files for every schema change. Set spring.jpa.hibernate.ddl-auto=validate so Hibernate checks that the schema matches your entity mappings without touching it. Flyway runs first, applies any pending migrations, and Hibernate then validates against the result.
The development workflow changes: instead of editing an entity and trusting Hibernate to catch up, you write a migration file. Yes, that is more work. In exchange, you get a versioned, auditable history of every schema change, the ability to test migrations against a copy of production data before deploying, and a clear rollback path if you catch a bad migration early enough.
The validate Mode in Production
If you are not ready to introduce Flyway, the minimum safe setting for any non-embedded database is validate. It changes nothing. It fails fast if the schema and your entities disagree, surfacing drift before the application starts serving requests. The schema-update question stays unanswered, but startup stays out of the way.
The combination to avoid is update in any environment where the database has data you care about. That includes staging. If staging runs update, you are not testing migrations. You are skipping them. The first time you run a real migration is production.
The Line to Find Right Now
Search your codebase for ddl-auto. If any config file sets it to update, that is the problem. Change it to none if you are managing migrations externally, or validate if you want Hibernate to catch mismatches at startup. If the entity mappings have already diverged from the schema, write a Flyway migration to close the gap. That is a one-time cost. Running update in production is an ongoing risk with no upside that a migration tool does not also provide.
Comments (0)