Database Migrations with Flyway
Reviewed & published by Brayan K
Evolve your schema safely with versioned SQL migrations. Drop V1__init.sql and V2__add_col.sql under db/migration and Spring Boot runs them automatically on startup.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should have built a complete REST API and understand how Spring Boot connects to a database via a DataSource and JPA entities.
What You'll Learn
- Why schema migrations exist
- Versioned scripts: V1__init.sql, V2__add_col.sql
- The flyway_schema_history table
- Auto-run on Spring Boot startup + spring.flyway.*
- Repeatable migrations and baselining
- Why migrations beat ddl-auto=update in production
The Big Idea — Version Control for Your Schema
💡 Analogy: Git records every change to your code as an ordered series of commits. Flyway does the same for your database: each migration (V1, V2, V3 ...) is a commit to the schema, applied in order and recorded in history. Anyone can replay the whole sequence from scratch and arrive at exactly the same database.
You describe each change once, as a script; Flyway guarantees it runs everywhere, exactly once, in order.
1️⃣ Your First Migration: V1__init.sql
Place SQL files under src/main/resources/db/migration. The first one creates your initial tables. Flyway applies it once and records it.
-- src/main/resources/db/migration/V1__init.sql
-- The FIRST migration. Flyway applies it once and records it in
-- flyway_schema_history so it never runs again.
CREATE TABLE app_user (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX idx_app_user_email ON app_user (email);2️⃣ Evolving the Schema: V2__add_col.sql
Never edit an applied migration — add a new one. Flyway sees version 2 is higher than the applied version 1 and runs it on the next startup.
-- src/main/resources/db/migration/V2__add_col.sql
-- A LATER migration. Flyway sees version 2 > the highest applied
-- version (1) and runs it on the next startup.
ALTER TABLE app_user
ADD COLUMN display_name VARCHAR(100);
-- Migrations can also move data, not just change structure:
UPDATE app_user
SET display_name = split_part(email, '@', 1)
WHERE display_name IS NULL;3️⃣ Configuring Flyway with spring.flyway.*
Flyway is auto-configured the moment it is on the classpath. Tune it with spring.flyway.* properties — including baseline-on-migrate for adopting an existing database.
# src/main/resources/application.properties
# Flyway is auto-configured once it is on the classpath.
# These are the common knobs (all optional):
spring.flyway.enabled=true
spring.flyway.locations=classpath:db/migration
# Tell Flyway to adopt an EXISTING, already-populated database
# instead of failing because V1 objects already exist:
spring.flyway.baseline-on-migrate=true
spring.flyway.baseline-version=1
# In production you usually DISABLE Hibernate's schema guessing
# and let migrations own the schema:
spring.jpa.hibernate.ddl-auto=validate4️⃣ Repeatable Migrations (the R prefix)
Repeatable migrations have no version and re-run whenever their checksum changes, after all versioned ones. They suit views, functions and procedures you keep in sync.
-- src/main/resources/db/migration/R__active_users_view.sql
-- A REPEATABLE migration (R prefix, NO version number).
-- Flyway re-applies it whenever its checksum changes, AFTER all
-- pending versioned migrations. Ideal for views and procedures.
CREATE OR REPLACE VIEW active_users AS
SELECT id, email, display_name
FROM app_user
WHERE display_name IS NOT NULL;5️⃣ The Idea, Concretely
To make the apply-once-then-skip behaviour tangible, here is a plain-Java stand-in modelling the version history Flyway keeps — with real output.
import java.util.*;
public class Main {
// A tiny stand-in showing the IDEA behind Flyway: keep a history of
// applied versions and only run migrations whose version is higher.
record Migration(int version, String description) {}
static List<String> applyPending(List<Migration> available, Set<Integer> applied) {
List<String> ran = new ArrayList<>();
available.stream()
.sorted(Comparator.comparingInt(Migration::version))
.filter(m -> !applied.contains(m.version()))
.forEach(m -> {
ran.add("V" + m.version() + "__" + m.description());
applied.add(m.version()); // record in "schema history"
});
return ran;
}
public static void main(String[] args) {
List<Migration> all = List.of(
new Migration(1, "init"),
new Migration(2, "add_col")
);
Set<Integer> history = new HashSet<>(); // empty DB
System.out.println("first start: " + applyPending(all, history));
System.out.println("history now: " + new TreeSet<>(history));
System.out.println("second start: " + applyPending(all, history)); // nothing pending
}
}// 🎯 YOUR TURN — replace each ___ using the hint beside it.
import java.util.*;
public class Main {
record Migration(String filename, String sql) {
int version() { return Integer.parseInt(filename.substring(1, filename.indexOf("__"))); }
String description() { return filename.substring(filename.indexOf("__") + 2).replace(".sql", "").replace("_", " "); }
}
public static void main(String[] args) {
List<Migration> onDisk = new ArrayList<>(List.of(
new Migration("V3__add_index.sql", "CREATE INDEX ..."),
new Migration("V1__create_book.sql", "CREATE TABLE book ..."),
new Migration("V2__add_pages.sql", "ALTER TABLE book ...")
));
// 1) Files come off disk in any order. The VERSION decides, not
// the filesystem — get this wrong and the schema is built wrong.
onDisk.___(Comparator.comparingInt(Migration::version)); // 👉 replace ___ with sort
Set<Integer> applied = new LinkedHashSet<>(List.of(1));
for (Migration m : onDisk) {
// 2) Already in the history table? Then it must not run again.
if (applied.___(m.version())) { // 👉 replace ___ with contains
System.out.println("skip V" + m.version() + " (" + m.description() + ") - already applied");
___; // 👉 replace ___ with continue
}
System.out.println("apply V" + m.version() + " (" + m.description() + ")");
// 3) Record it, so the next run skips it too.
applied.___(m.version()); // 👉 replace ___ with add
}
// 4) The highest version that has run is the schema version.
System.out.println("Schema version now: " + Collections.___(applied)); // 👉 replace ___ with max
try {
new Migration("V1__create_book.sql", "DROP TABLE book").version();
if (applied.contains(1)) throw new IllegalStateException("V1 has already run - edit it and the checksum no longer matches");
} catch (IllegalStateException e) {
System.out.println("Refused: " + e.getMessage());
}
}
}🧠 Quick Recall
Predict before you peek.
Answer: on the classpath under db/migration — i.e. src/main/resources/db/migration.
Answer: it reads the flyway_schema_history table and applies only newer versions.
Answer: migrations are explicit, reviewable, ordered and reproducible; ddl-auto=update guesses and never drops columns.
Common Errors
- ❌ Editing an already-applied migration. Checksum mismatch fails startup. Fix: add a new V script instead.
- ❌ One underscore in the name. e.g. V1_init.sql. Fix: use exactly two: V1__init.sql.
- ❌ Adding Flyway to a populated DB with no baseline. V1 objects already exist. Fix: set baseline-on-migrate=true.
- ❌ Leaving ddl-auto=update on in production. Unpredictable schema drift. Fix: use validate and let Flyway own the schema.
- ❌ Giving a repeatable migration a version. It then behaves like a versioned one. Fix: use the bare R__ prefix.
Pro Tips
- 💡 Treat migrations as immutable once merged — always roll forward with a new script.
- 💡 Use ddl-auto=validate so Hibernate checks, but never changes, the migrated schema.
- 💡 Keep migrations small and focused — one logical change per file is easier to review and revert.
- 💡 Liquibase is the main alternative if you prefer XML/YAML changelogs; Spring Boot integrates both.
📋 Quick Reference
| Concept | Form / property | Note |
|---|---|---|
| location | classpath:db/migration | default scan path |
| versioned | V2__add_col.sql | runs once, in order |
| repeatable | R__view.sql | re-runs on checksum change |
| history | flyway_schema_history | tracks applied versions |
| baseline | spring.flyway.baseline-on-migrate | adopt existing DB |
| validate | ddl-auto=validate | prod schema check |
📚 Resources / Keep Learning
- 📘 Spring Boot — Database Initialization (docs)
- 📗 Flyway Documentation
- 📗 Liquibase Documentation (the alternative)
🎉 Lesson Complete!
You now know how Flyway applies versioned SQL migrations on Spring Boot startup, how the flyway_schema_history table tracks what has run, how to configure it with spring.flyway.*, what repeatable migrations and baselining are for, and why migrations beat ddl-auto=update in production.
Practice quiz
What does Flyway do for a Spring Boot application?
- Applies versioned database migrations automatically
- Compiles SQL to Java
- Caches HTTP responses
- Generates REST controllers
Answer: Applies versioned database migrations automatically. Flyway is a database migration tool: it applies versioned SQL scripts to bring your schema to the desired state, and Spring Boot runs it on startup.
Where does Flyway look for migration scripts by default in a Spring Boot app?
- the project root
- META-INF/sql
- src/main/resources/db/migration
- src/main/java/migrations
Answer: src/main/resources/db/migration. By default Flyway scans classpath:db/migration, which maps to src/main/resources/db/migration in a standard Spring Boot project.
Which file name is a valid versioned migration?
- migration-1.sql
- V1__init.sql
- 1-init.sql
- init_v1.sql
Answer: V1__init.sql. Versioned migrations use the form V<version>__<description>.sql, e.g. V1__init.sql, with a capital V, the version, two underscores, then a description.
What is the flyway_schema_history table used for?
- Caching query results
- Storing application data
- Logging HTTP requests
- Tracking which migrations have already been applied
Answer: Tracking which migrations have already been applied. flyway_schema_history records every applied migration with its version, checksum and status so Flyway knows what still needs to run.
When does Spring Boot run Flyway migrations by default?
- Automatically at application startup
- Only on the first HTTP request
- Never, you call it manually
- On a nightly schedule
Answer: Automatically at application startup. With Flyway on the classpath, Spring Boot auto-runs pending migrations during application startup before the app is ready to serve traffic.
Which prefix marks a repeatable migration?
- B (e.g. B__baseline.sql)
- R (e.g. R__create_view.sql)
- V (e.g. V1__create_view.sql)
- U (e.g. U1__undo.sql)
Answer: R (e.g. R__create_view.sql). Repeatable migrations use the R prefix and have no version. They re-run whenever their checksum changes, which suits views and stored procedures.
Which statement about ddl-auto=update in production is correct?
- It is required by Flyway
- It is the safest option
- It reliably drops unused columns
- It can silently alter the schema and never drops columns, so migrations are preferred
Answer: It can silently alter the schema and never drops columns, so migrations are preferred. ddl-auto=update guesses schema changes, applies them unpredictably, and cannot handle data migrations or column removal, so it is unsafe for production. Versioned migrations are explicit, reviewable and repeatable.
What is baselining in Flyway?
- Disabling migrations
- Deleting all data
- Marking an existing populated database at a starting version so Flyway can manage it going forward
- Renaming the schema
Answer: Marking an existing populated database at a starting version so Flyway can manage it going forward. Baselining tells Flyway that an existing database is already at a given version, so it only applies migrations newer than that baseline instead of trying to recreate the whole schema.
Which property prefix configures Flyway in application.properties?
- spring.datasource.*
- spring.flyway.*
- spring.migration.*
- flyway.boot.*
Answer: spring.flyway.*. Flyway settings in Spring Boot use the spring.flyway.* prefix, for example spring.flyway.enabled, spring.flyway.locations and spring.flyway.baseline-on-migrate.
What does Liquibase represent in this context?
- An alternative migration tool to Flyway, often using XML/YAML changelogs
- A replacement for the JVM
- A logging framework
- A web server
Answer: An alternative migration tool to Flyway, often using XML/YAML changelogs. Liquibase is the main alternative to Flyway. It describes changes in XML, YAML, JSON or SQL changelogs and is also auto-integrated by Spring Boot.
Continue this course
- Previous: Capstone: A Complete REST API
- Next: API Docs with OpenAPI & springdoc — Auto-generate Swagger UI and an OpenAPI spec with springdoc annotations
- Quick reference: Java cheat sheet
Frequently asked questions
What is Flyway and why does a Spring Boot app need it?
Flyway is a database migration tool. Instead of changing your schema by hand or hoping an ORM guesses correctly, you write small, versioned SQL scripts that describe each change, and Flyway applies them in order. When Flyway is on the classpath, Spring Boot auto-configures it and runs any pending migrations during startup, before the application begins serving traffic. This means every environment (a developer laptop, CI, staging, production) ends up with an identical, reproducible schema, and the exact history of changes lives in version control alongside your code. Flyway tracks what it has already done in a flyway_schema_history table, so each migration runs exactly once.
How do I name and organise migration files?
Versioned migrations follow the pattern V<version>__<description>.sql: a capital V, the version (such as 1, 2, or 2.1), exactly two underscores, a human-readable description, and the .sql extension, for example V1__init.sql and V2__add_col.sql. By default Spring Boot expects them on the classpath under db/migration, which is src/main/resources/db/migration in a standard project. Flyway sorts by version and applies any whose version is higher than the highest already recorded. Repeatable migrations use an R prefix with no version (R__active_users_view.sql) and re-run whenever their checksum changes, which is perfect for views, functions and stored procedures that you want to keep in sync.
What is the flyway_schema_history table?
flyway_schema_history is a small bookkeeping table Flyway creates and maintains in your database. Each row records one applied migration: its version, description, type, the script name, a checksum of the script content, who applied it, when, how long it took, and whether it succeeded. On startup Flyway reads this table to determine the highest applied version, then runs only the newer pending scripts. The checksum also lets Flyway detect if an already-applied migration file was edited after the fact, which it treats as an error so you cannot silently change history. You should never edit this table by hand; let Flyway manage it.
Why are migrations better than spring.jpa.hibernate.ddl-auto=update in production?
ddl-auto=update asks Hibernate to compare your entities to the live schema and apply whatever differences it infers. That is convenient in early development but dangerous in production: it can only add things (it never drops columns or tables), it cannot perform data migrations, its behaviour varies between Hibernate versions, and the changes it makes are neither reviewed nor recorded. Versioned migrations are the opposite: each change is an explicit, reviewable SQL file, applied in a deterministic order, recorded in history, and identical across every environment. The common production setup is spring.jpa.hibernate.ddl-auto=validate, which makes Hibernate verify that the schema produced by your migrations matches your entities without changing anything itself.
How do I introduce Flyway into a database that already has tables?
Use baselining. If you add Flyway to an existing, populated database, your first run would otherwise fail because the objects in V1 already exist. Setting spring.flyway.baseline-on-migrate=true tells Flyway to create the history table and record a baseline version (spring.flyway.baseline-version, often 1) representing the current state, after which it only applies migrations newer than that baseline. In practice you snapshot the existing schema into an early migration for documentation, baseline at that version, and then write all future changes as new V scripts. From that point on the existing database is fully managed by Flyway like any greenfield one.