Back to Indexing
Q4Question 4 of 18

CREATE INDEX CONCURRENTLY and Versioned Migrations Over hibernate.ddl-auto

Key Concepts
  • `CREATE INDEX CONCURRENTLY` builds an index without taking an exclusive lock that blocks writes, keeping the table available during the build.
  • A plain `CREATE INDEX` locks the table for the entire build, which can last minutes on a large table and effectively cause an outage.
  • The trade-offs of CONCURRENTLY are: slower build, more resources, cannot run inside a transaction, and can leave an invalid index on failure.
  • Versioned migrations (Flyway/Liquibase) apply ordered, reviewed, immutable SQL scripts, giving deterministic, reproducible schema changes.
  • `hibernate.ddl-auto=update` is non-deterministic, unreviewed, never drops or renames safely, and gives no rollback or history.
  • In production, set `hibernate.ddl-auto=validate` so Hibernate only checks the schema matches entities, while all real changes go through migrations.

Definition

`CREATE INDEX CONCURRENTLY` builds without blocking writes; versioned migrations give reviewed, ordered, reproducible schema changes – never let Hibernate auto-alter production.
  • CREATE INDEX CONCURRENTLY – Builds an index without taking an exclusive lock that blocks writes, so the table remains available for reads and writes during the build, at the cost of being slower and requiring two table scans.
  • Plain CREATE INDEX – Takes a lock that blocks writes (and in some engines reads) for the entire build, which can last minutes on a large table.
  • Versioned migrations – Tools like Flyway and Liquibase apply ordered, reviewed, immutable SQL scripts, giving a controlled, auditable, reproducible schema history.
  • hibernate.ddl-auto=update – Lets Hibernate silently guess and alter the schema at startup, which is unreviewed, non-deterministic, and unsafe for production.
  • Production standard – Set hibernate.ddl-auto=validate so Hibernate only verifies the schema matches entities, while all real changes go through migrations.

Building an index the normal way is like closing a busy National Bank of India counter to reorganize its files – customers wait. CONCURRENTLY is like reorganizing the files while the counter keeps serving, slower but no queue. And letting Hibernate auto-update the schema is like letting an intern rearrange the vault automatically each morning based on their best guess – sometimes right, sometimes a disaster, and nobody reviewed it. Versioned migrations are the opposite: a written, signed-off, numbered change order that is applied the same way in every branch, every time.

Understanding the Concepts

Why does a plain CREATE INDEX block writes?

A plain CREATE INDEX takes a lock that blocks writes (and in some engines reads) on the table for the entire build. On a busy banking table with millions of rows, that build can take minutes.

SQL
-- National Bank of India: 200 million transactions
-- Plain CREATE INDEX locks the table for the entire build
CREATE INDEX ix_txn_amount ON transactions(amount);
-- During the build: all INSERTs, UPDATEs, DELETEs are blocked
-- Build time: 5-10 minutes on 200M rows
-- Result: payments queue up, API timeouts, effectively an outage

How does CREATE INDEX CONCURRENTLY solve this?

CREATE INDEX CONCURRENTLY builds the index without taking an exclusive lock. It scans the table once to build the index, then scans again to catch up on rows changed during the first scan.

SQL
-- PostgreSQL: build without blocking writes
CREATE INDEX CONCURRENTLY ix_txn_amount
    ON transactions(amount);

-- During the build:
--   Reads: allowed
--   Writes: allowed
--   Build time: ~2x longer than plain CREATE INDEX
--   But: ZERO downtime for the application

What are the trade-offs of CONCURRENTLY?

| Feature | Plain CREATE INDEX | CREATE INDEX CONCURRENTLY |
|---|---|---|
| Table writes during build | Blocked | Allowed |
| Build speed | Faster | ~2x slower |
| Resource usage | Lower | Higher (two scans) |
| Transaction support | Can run inside a txn | Cannot (must be outside) |
| Failure behavior | Rolls back cleanly | May leave invalid index |
| Downtime | Yes (lock duration) | No |

SQL
-- If CONCURRENTLY fails, it leaves an INVALID index
-- Check for invalid indexes:
SELECT indexrelid::regclass, indisvalid
FROM   pg_index
WHERE  NOT indisvalid;

-- Fix: drop the invalid index and retry
DROP INDEX CONCURRENTLY ix_txn_amount;
CREATE INDEX CONCURRENTLY ix_txn_amount ON transactions(amount);

Why is hibernate.ddl-auto=update dangerous in production?

hibernate.ddl-auto has modes: none, validate, update, create, create-drop. The update mode makes Hibernate compare its entity mappings to the live schema at startup and automatically issue ALTERs.

YAML
# application.yml - DANGEROUS in production
spring:
  jpa:
    hibernate:
      ddl-auto: update  # Hibernate guesses and alters the schema

The problems:

TEXT
1. Non-deterministic: DDL depends on Hibernate version and dialect
   - What runs in staging may differ from production

2. Unreviewed: nobody sees the SQL before it hits the database
   - A small entity change can trigger a destructive ALTER

3. Only additive: never drops or renames safely
   - Removed fields leave orphaned columns
   - Renames become add + orphan (not a true rename)

4. No rollback, no history
   - No versioned record of what changed when
   - Cannot roll back a bad change

5. Unpredictable locks at startup
   - Can take long locks on large tables, delaying deployment

What is the correct production setup?

Use versioned migrations for all schema changes and set hibernate.ddl-auto=validate.

YAML
# application.yml - SAFE for production
spring:
  jpa:
    hibernate:
      ddl-auto: validate  # only checks, never alters
  flyway:
    enabled: true
    locations: classpath:db/migration
SQL
-- Flyway migration: V3__add_amount_index.sql
-- Reviewed in pull request, applied exactly once
CREATE INDEX CONCURRENTLY ix_txn_amount
    ON transactions(amount);

How do versioned migrations work?

With Flyway or Liquibase, each schema change is a numbered, immutable script that is applied in order exactly once.

TEXT
db/migration/
  V1__create_accounts.sql
  V2__create_transactions.sql
  V3__add_amount_index.sql
  V4__add_status_column.sql
SQL
-- V1__create_accounts.sql
CREATE TABLE accounts (
    account_id   VARCHAR(20) PRIMARY KEY,
    customer_name VARCHAR(100) NOT NULL,
    balance      DECIMAL(15,2) NOT NULL DEFAULT 0.00
);

-- V3__add_amount_index.sql
-- Note: CONCURRENTLY requires Flyway to run outside a transaction
-- flyway.postgresql.transactional.lock = false
CREATE INDEX CONCURRENTLY ix_txn_amount
    ON transactions(amount);

How do you run CONCURRENTLY with Flyway?

Flyway runs each migration inside a transaction by default, but CREATE INDEX CONCURRENTLY cannot run inside a transaction. The fix depends on the Flyway version.

JAVA
// com.bank.config.FlywayConfig
package com.bank.config;

import org.flywaydb.core.Flyway;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;

@Configuration
public class FlywayConfig {

    @Bean
    public Flyway flyway(DataSource dataSource) {
        return Flyway.configure()
            .dataSource(dataSource)
            .locations("classpath:db/migration")
            .load();
    }
}
SQL
-- In the migration file, use Flyway's non-transactional marker:
-- For Flyway 9+, name the file with a specific pattern
-- or set the migration as non-transactional in the config

-- V3__add_amount_index.sql
-- This must run outside a transaction
CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_txn_amount
    ON transactions(amount);

What other DDL should be non-blocking?

The same philosophy applies to all DDL on busy tables.

SQL
-- Add a nullable column: instant, no rewrite
ALTER TABLE accounts ADD COLUMN email VARCHAR(255);

-- Add a NOT NULL column with default: may rewrite table (pre PG11)
-- In PostgreSQL 11+, adding with DEFAULT is instant
ALTER TABLE accounts ADD COLUMN status VARCHAR(10)
    NOT NULL DEFAULT 'ACTIVE';

-- Validate a constraint in two phases:
-- Phase 1: add as NOT VALID (instant, no lock)
ALTER TABLE transactions
    ADD CONSTRAINT fk_account
    FOREIGN KEY (account_id) REFERENCES accounts(account_id)
    NOT VALID;

-- Phase 2: validate existing rows (no exclusive lock)
ALTER TABLE transactions
    VALIDATE CONSTRAINT fk_account;

What should a senior engineer know?

  • In production, always use CREATE INDEX CONCURRENTLY for busy tables.
  • Set hibernate.ddl-auto=validate (or none) and use Flyway/Liquibase for all schema changes.
  • Review every migration in a pull request before it runs against production.
  • Non-blocking DDL patterns: nullable columns, two-phase constraint validation, concurrent index builds.
  • Monitor Flyway's schema-history table (flyway_schema_history) for applied migrations.
SQL
-- Check Flyway migration history
SELECT installed_rank, version, description, success
FROM   flyway_schema_history
ORDER BY installed_rank;

What are common interview follow-up questions?

  • What happens if CREATE INDEX CONCURRENTLY fails partway through?
  • How does Flyway ensure migrations are applied exactly once?
  • What is the difference between ddl-auto=validate and ddl-auto=none?
  • How do you add a NOT NULL column to a large table without locking it?

Visual Overview

DIAGRAM
┌─────────────────────────────────────────────────┐
│   Schema Change Workflow                        │
├─────────────────────────────────────────────────┤
│                                                 │
│  Wrong (hibernate.ddl-auto=update):             │
│  ┌──────────┐  ┌───────────────┐                │
│  │ App      │─►│ Hibernate     │                │
│  │ starts   │  │ guesses DDL   │                │
│  └──────────┘  │ runs ALTER    │                │
│                │ unreviewed!   │                │
│                └───────────────┘                │
│                                                 │
│  Correct (Flyway + ddl-auto=validate):          │
│  ┌──────────┐  ┌───────────────┐                │
│  │ Developer│─►│ Write SQL     │                │
│  │          │  │ migration     │                │
│  └──────────┘  └───────┬───────┘                │
│                        ▼                        │
│               ┌───────────────┐                 │
│               │ PR review     │                 │
│               └───────┬───────┘                 │
│                       ▼                         │
│               ┌───────────────┐                 │
│               │ Flyway applies│                 │
│               │ in order,     │                 │
│               │ exactly once  │                 │
│               └───────┬───────┘                 │
│                       ▼                         │
│               ┌───────────────┐                 │
│               │ Hibernate     │                 │
│               │ validates     │                 │
│               │ (no ALTER)    │                 │
│               └───────────────┘                 │
└─────────────────────────────────────────────────┘

Key Takeaway

Building an index on a busy table requires CREATE INDEX CONCURRENTLY because a plain CREATE INDEX takes a lock that blocks writes for the whole build, and on a table with hundreds of millions of rows that build can last minutes, during which live transactions queue up and time out – effectively an outage. The concurrent build scans the table without that blocking lock, letting reads and writes continue. The trade-offs are that it is slower, uses more resources, cannot run inside a transaction, and can leave an invalid index if it fails, which you drop and retry, but on a live production table the availability is worth it. The same philosophy applies to all DDL – prefer the non-blocking or short-lock variant so you never freeze a live table.

For managing schema changes, use versioned migrations with Flyway or Liquibase rather than hibernate.ddl-auto=update in production, and set ddl-auto=validate so Hibernate only checks that the live schema matches the entities and refuses to start if not, while all real changes go through migrations. The reason is that update is non-deterministic across Hibernate versions and dialects, unreviewed so a small entity change can trigger a destructive ALTER nobody approved, only additive so removed fields orphan columns and renames become add-plus-orphan, and it keeps no history and offers no rollback. Versioned migrations are ordered, immutable, reviewed in a pull request, applied exactly once, and recorded in a schema-history table, giving a reproducible schema across every environment.

At the National Bank of India a plain index build on the 300-million-row transactions table once locked writes for eight minutes and timed out payments, and a service running ddl-auto=update once orphaned a column during a rename and drifted from staging. After both incidents Pankaj mandated CONCURRENTLY for busy-table indexes and Flyway with ddl-auto=validate. Rajesh combines the two: changes are reviewed versioned migrations, and inside them he uses non-blocking DDL like CREATE INDEX CONCURRENTLY, run outside a transaction, so the change is both auditable and zero-downtime.

Remember one sentence: build busy indexes concurrently to avoid blocking writes, and own the schema with reviewed versioned migrations while Hibernate only validates.

---

Share
Use ← → arrow keys to navigate