Skip to main content
the lies your orm tells you

Schema Generation in Production (and Why You Should Never)

5 min read Chapter 13 of 30

Schema Generation in Production (and Why You Should Never)

The Lie

ddl-auto=update keeps your schema in sync with your entities. Change a field, restart the application, and the database matches your code.

The Reality

Hibernate’s schema generation is a development convenience that becomes a production liability. ddl-auto=update compares your entity model to the database metadata and generates ALTER TABLE statements to close the gap. It does this without understanding your data, your indexes, your constraints, or the locking behavior of the ALTER statements it generates.

What update does:

  • Adds new columns (nullable, no default)
  • Adds new tables
  • Adds new foreign key constraints

Schema Drift Over Time

This chart tracks a single active table over 18 months of ddl-auto=update deployments. Entity column count fluctuates as fields are added and removed. The actual database column count only ever grows: added columns stay, renamed columns leave a ghost, removed fields leave orphaned data. By month 18, the production table carries 12 columns that no entity references and 4 manually-added structures that no migration tracks.

What update does not do:

  • Drop columns that no longer map to entity fields
  • Drop tables that no longer have entities
  • Rename columns (it creates a new one, leaves the old one)
  • Change column types (VARCHAR(255) to TEXT, for example)
  • Modify existing constraints
  • Add indexes (unless mapped with @Index)
  • Reorder columns

This means your production schema silently diverges from your entity model. Every removed field leaves a ghost column. Every type change leaves the old type in place. After a year of ddl-auto=update, your production schema looks nothing like what Hibernate thinks it looks like.

The Evidence

// Version 1 of the entity
@Entity
@Table(name = "customers")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(name = "full_name")
    private String fullName;

    @Column(name = "email")
    private String email;

    @Column(name = "phone_number")
    private String phoneNumber;
}

// Generated DDL on first run:
// create table customers (
//     id bigint not null,
//     full_name varchar(255),
//     email varchar(255),
//     phone_number varchar(255),
//     primary key (id)
// )

Now you refactor. phoneNumber becomes phone, and you add a status enum.

// Version 2 of the entity
@Entity
@Table(name = "customers")
public class Customer {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;

    @Column(name = "full_name")
    private String fullName;

    @Column(name = "email")
    private String email;

    @Column(name = "phone")
    private String phone;

    @Enumerated(EnumType.STRING)
    @Column(name = "status")
    private CustomerStatus status;
}

// What Hibernate generates with ddl-auto=update:
// alter table customers add column phone varchar(255)
// alter table customers add column status varchar(255)
//
// What Hibernate does NOT generate:
// alter table customers drop column phone_number
// (the old column stays, with all its data, forever)

After this restart, your customers table has both phone_number and phone columns. The data in phone_number is orphaned. Hibernate reads from and writes to phone, which is null for all existing rows.

Your application did not crash. Your tests passed. Your data is silently wrong.

The Fix

Stop using ddl-auto in production. Use validate or none.

# application.yml
spring:
  jpa:
    hibernate:
      ddl-auto: validate # Fails on startup if schema doesn't match entities

Adopt a migration tool. Flyway is the simpler choice. Liquibase offers more flexibility for multi-database deployments.

-- V1__create_customers.sql
CREATE TABLE customers (
    id BIGSERIAL PRIMARY KEY,
    full_name VARCHAR(255) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    phone_number VARCHAR(50)
);

CREATE INDEX idx_customers_email ON customers (email);
-- V2__rename_phone_add_status.sql
ALTER TABLE customers RENAME COLUMN phone_number TO phone;
ALTER TABLE customers ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE';

-- Backfill existing data
UPDATE customers SET status = 'ACTIVE' WHERE status IS NULL;

The migration handles what Hibernate cannot: renaming the column preserves data, the status column gets a default, existing rows get backfilled, and the old column name disappears.

The Cost Model

The cost of ddl-auto=update is not immediate. It accumulates.

After 6 months of entity changes without migrations, you face:

  • Ghost columns: 5-15 abandoned columns per table in an active codebase. Each consumes storage and confuses anyone who reads the schema directly.
  • Missing indexes: Hibernate adds indexes only for @Index annotations. Any index you need for query performance but did not annotate is missing.
  • No data migration: Column type changes, data backfills, and constraint additions require SQL that Hibernate cannot generate.
  • Lock hazards: ALTER TABLE on large tables in PostgreSQL can acquire ACCESS EXCLUSIVE locks. Flyway migrations let you use CREATE INDEX CONCURRENTLY and other non-locking alternatives. Hibernate’s DDL generation does not.

For a team with fewer than 3 developers and a database under 10,000 rows, ddl-auto=update in a staging environment is tolerable. In production, with real data and real users, it is technical debt that compounds with every deployment.