Mastering Database Migrations in Java: A Hands-On Guide with Quarkus and Flyway
Learn how to version, evolve, and manage your database schema like a pro, using modern Java, Quarkus Dev Services, and Flyway.
Welcome, future enterprise Java heroes! You already know how to write Java classes, build REST APIs, and maybe even talk to a database. But here’s a reality check: changing a database in production is scary:
Adding a column? Fine.
Dropping a table? Maybe fine.
Deploying a new version of your app and realizing the database is now out of sync?
Welcome to production panic.
This is why tools like Flyway exist, and why every serious application uses some form of database migrations.
In this tutorial, you’ll:
Understand why database migrations matter.
Create, evolve, and manage your database schema the right way.
Learn good habits that will make you a professional others trust.
Let’s goooooo! (Full source code in my Github repository!)
Why You Need Flyway (Before It's Too Late)
Imagine you work on a team. Your app stores customer data.
Today you add a new field called birthdate
to the customer
table. Easy, right?
You run ALTER TABLE
locally and everything works.
But your teammate pulls the latest code and their app crashes, because they don’t have your manual change.
Multiply that by a dozen developers, two environments (staging + production), and the need to roll back safely.
Without a tool, chaos is inevitable.
Flyway fixes this by:
Letting you describe schema changes as versioned files (
V1__create_table.sql
,V2__add_column.sql
, etc.)Applying them in order automatically.
Tracking which changes were already applied.
You control your database like you control your code: No surprises, no undocumented changes.
Setting the Stage: Quarkus + Flyway
Let’s build a real example using Quarkus.
First, you’ll need:
Java 17+ installed
Quarkus CLI (you can just use Maven but I like the CLI a lot!)
Maven installed
Podman installed (for Dev Services to spin up databases automatically)
If you’re ready, create a new Quarkus app:
quarkus create app org.acme:flyway-adventure \
--extensions='rest-jackson, jdbc-postgresql, hibernate-orm-panache, flyway' \
--no-code
cd flyway-adventure
Here’s what’s happening:
rest-jackson
: Adds Quarkus REST stack with Jackson supportjdbc-postgresql
: Adds the PostgreSQL driver.hibernate-orm-panache
: Adds Hibernate with the Quarkus optimized Panache features to access our entities later.flyway
: Adds Flyway migration magic.--no-code
: Starts with a clean slate — no example code.
Quarkus makes it ridiculously easy to add extensions.
You can also add extensions to an existing project later with:
quarkus extension add jdbc-postgresql flyway
Now, open src/main/resources/application.properties
and add:
# Database config (Dev Services will start PostgreSQL for us!)
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=demo
quarkus.datasource.password=demo
# Hibernate SQL Statement logging
quarkus.hibernate-orm.log.sql=true
# Flyway settings
quarkus.flyway.migrate-at-start=true
quarkus.flyway.baseline-on-migrate=true
What does this do?
migrate-at-start
: Flyway runs migrations when the app boots up. No manual steps.baseline-on-migrate
: If the database already has tables, it marks a starting point to avoid reapplying old migrations.
Small miracle:
You didn’t need to install PostgreSQL.
Quarkus Dev Services will automatically spin up a container for you when you run the app. No config, no excuses.
Creating Your First Migration
Flyway expects migrations to live in:
src/main/resources/db/migration/
Let's create a first migration:
Create a file:
src/main/resources/db/migration/V1__create_person_table.sql
CREATE TABLE person (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
);
create sequence Person_SEQ start with 1 increment by 50;
Rules you must follow:
Files must start with
V
followed by a number (V1
,V2
, etc.)Separate version and description with double underscores (
__
).Use plain
.sql
files by default.
Migrations are applied in version order — no "magic" reordering.
Running the App and Applying Migrations
Ready for the magic?
Start Quarkus in dev mode:
quarkus dev
You should see output like:
INFO [org.fly.cor.int.com.DbMigrate] (Quarkus Main Thread) Current version of schema "public": << Empty Schema >>
INFO [org.fly.cor.int.com.DbMigrate] (Quarkus Main Thread) Migrating schema "public" to version "1 - create person table"
INFO [org.fly.cor.int.com.DbMigrate] (Quarkus Main Thread) Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.003s)
What happened?
Quarkus started a PostgreSQL container in the background.
It connected to it using your
application.properties
.Flyway found your
V1__create_person_table.sql
.Flyway applied it and recorded the migration in a table called
flyway_schema_history
.
Result:
Your database now has a person
table. No manual steps. No mistakes. Take a look at the DevUI and view the Database with the Agroal - Database connection pool extension.
Evolving Your Database
Let’s simulate a real-world scenario:
Your boss now says, "We need to know when the person registered!"
Fine. Let’s add a registered_at
column.
Create another migration:
src/main/resources/db/migration/V2__add_registered_at_column.sql
ALTER TABLE person ADD COLUMN registeredAt TIMESTAMP DEFAULT now();
Restart your Quarkus instance: You’ll see:
INFO [org.fly.cor.int.com.DbMigrate] (Quarkus Main Thread) Migrating schema "public" to version "2 - add registered at column"
Boom.
Now every person will have a registered_at
timestamp.
You didn’t touch existing data manually. Flyway applied it safely
.
Bonus: Using the Table in Your Code
Let's quickly use this table in a REST API.
Create a simple entity:
src/main/java/org/acme/Person.java
package org.acme;
import java.time.LocalDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
@Entity
public class Person extends PanacheEntity {
public String name;
public String email;
public LocalDateTime registeredAt;
}
Then a basic REST resource:
src/main/java/org/acme/PersonResource.java
package org.acme;
import java.util.List;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/people")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PersonResource {
@POST
@Transactional
public Response create(Person person) {
person.persist();
return Response.status(Response.Status.CREATED).entity(person).build();
}
@GET
public List<Person> list() {
return Person.listAll();
}
}
Try it:
curl -X POST -H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com"}' \
http://localhost:8080/people
If you look at the Quarkus console log you can see the Hibernate select which is executed:
[Hibernate]
select
nextval('Person_SEQ')
[Hibernate]
insert
into
Person
(email, name, registeredAt, id)
values
(?, ?, ?, ?)
And list:
curl http://localhost:8080/people
With the resulting console log:
[Hibernate]
select
p1_0.id,
p1_0.email,
p1_0.name,
p1_0.registeredAt
from
Person p1_0
Congratulations! You have a real, evolving database-backed API!
Common Questions Beginners Ask
Q: What if two developers create migrations at the same time?
A: You should each use a new version number (V3
, V4
, etc.). Merge conflicts can happen, but Flyway will detect missing migrations at startup.
Q: Can I undo migrations?
A: Flyway Community Edition doesn’t support rollback scripts. You need to either manually write down-reverse migrations or use paid Flyway Teams features.
Q: Should I test my migrations?
A: Yes! Ideally, start a fresh database, run all migrations, and verify the schema is correct.
Level Up: Production Readiness
For local development, migrate-at-start=true
is fine.
In production, bigger teams usually:
Run migrations separately (e.g., CI/CD step, Kubernetes job)
Version control migration files in Git
Backup the database before deploying
Flyway also works perfectly inside CI/CD pipelines:
mvn flyway:migrate
Or using standalone Docker containers.
Remember:
Once a migration file is applied in production, never change it.
Always create a new versioned file.
Final Words: Think Like an Architect
Enterprise development is about consistency and predictability.
Schema migrations are a huge part of that.
With Quarkus and Flyway:
You get rapid development (Dev Services + auto migrations).
You avoid surprises when collaborating.
You build trust with your team, your ops people, and your future self.
Be the developer who leaves the database better than they found it.