# Flyway, From Zero

> Version-control your database schema with Flyway: numbered, immutable migration files applied in order, so every environment ends up with the exact same schema.


---

# Flyway, From Zero

You changed a column on your laptop, it worked, and now staging is broken because nobody else has that column. Someone ran a script by hand last Tuesday and can't remember if prod got it. Your schema lives in tribal memory and Slack screenshots, and every deploy is a small prayer. Flyway turns that mess into something boring: your schema becomes a folder of numbered SQL files that run in order, exactly once, the same way everywhere.

This guide gets you from "I have a database and some SQL" to "every environment converges on the identical schema, and I can prove which migrations ran." No magic, no ORM required, no rollback fantasies.

## How to read this

Read the phases in order. Phase 1 builds the mental model: why a schema is code, and the three rules Flyway enforces. Phase 2 is the everyday loop: writing migrations, running them, repeatable migrations. Phase 3 is production reality: baselining a database that already exists, what happens when a migration fails, and why "rollback" mostly means writing the next migration. If you only have five minutes, read Phase 1 - it's the part that changes how you think.

## The phases

1. [Phase 1: A Schema Is Code](01-schema-is-code.md) - what Flyway actually is, the history table, and the three rules
2. [Phase 2: The Everyday Loop](02-the-everyday-loop.md) - writing V-migrations, running them, repeatable migrations
3. [Phase 3: Production Reality](03-production-reality.md) - baselining, failed migrations, and the rollback truth


---

# A Schema Is Code

Here's the thing nobody tells you when you start: your database schema is one of the most important pieces of code you own, and for years you probably treated it like it wasn't code at all. Your application lives in Git. Every line is reviewed, versioned, reproducible. Meanwhile the shape of your data - the tables, columns, indexes, constraints that your whole app depends on - got changed by people typing `ALTER TABLE` into a console and hoping.

That asymmetry is where the pain comes from. Two developers add a column with slightly different types. Staging has an index prod doesn't. A "quick fix" run by hand in production never makes it into the script other people run. The schema drifts, environment by environment, until "works on my machine" becomes a daily fact of life.

Flyway's entire reason to exist is to close that gap. It says: a change to your schema is a change to your code, so treat it like one. Put it in a file. Give it a version. Commit it. Apply it the same way everywhere. That's the whole idea, and everything else is mechanics.

## The mental model: an ordered list of changes

Don't think of Flyway as a tool that "manages your database." Think of it as something much simpler: a list of changes, in order, that it walks down.

Each change is a plain SQL file with a name like `V1__create_users.sql`. The number is the version. Flyway sorts these files by version, then applies them one at a time, top to bottom. Your database isn't a thing you describe - it's the *result* of replaying every change in sequence, exactly like rebuilding state by replaying a log.

```text
db/migration/
├── V1__create_users.sql      -- runs first
├── V2__add_email_to_users.sql -- runs second
└── V3__create_orders.sql      -- runs third
```

*What just happened:* a brand-new empty database, pointed at this folder, becomes a database with a `users` table (with an email column) and an `orders` table - because Flyway ran V1, then V2, then V3, in that order. Any empty database pointed at the same folder ends up identical. That convergence is the entire promise.

The filename format matters because Flyway parses it. A versioned migration is `V`, then the version, then two underscores, then a description, then `.sql`:

```text
V2__add_email_to_users.sql
│ │  │
│ │  └─ description (underscores show as spaces in logs)
│ └──── two underscores - the separator (one won't work)
└────── prefix V = "versioned migration", version 2
```

*What just happened:* that double underscore is a real rule, not a style choice. `V2_add_email.sql` with one underscore is not a valid migration name and Flyway will skip or reject it. Versions sort numerically and can have dots (`V2.1`, `V2.10`), so order is unambiguous.

## The history table: how Flyway remembers

The piece that makes all of this trustworthy is a table Flyway creates inside *your* database the first time it runs, called `flyway_schema_history`. This is its memory. Every migration that successfully applies gets a row.

```text
+---------+-------------+----------------------+---------+--------------------+---------+
| version | description | script               | success | installed_on       | checksum|
+---------+-------------+----------------------+---------+--------------------+---------+
| 1       | create users| V1__create_users.sql | true    | 2026-06-28 09:14   | -8821…  |
| 2       | add email   | V2__add_email...sql  | true    | 2026-06-28 09:14   | 1190…   |
+---------+-------------+----------------------+---------+--------------------+---------+
```

*What just happened:* before running anything, Flyway reads this table to learn what's already been applied. It sees versions 1 and 2 are done, so on the next run it only considers V3 and up. The history table is why Flyway never runs the same migration twice, and why it's safe to run on every deploy - it figures out what's left, applies only that, and stops.

That `checksum` column is doing quiet, important work, which leads us to the rules.

## The three rules

Flyway is opinionated, and its opinions are the source of its reliability. There are really three.

**Rule one: migrations run in order.** Versions are applied lowest to highest. You don't get to reorder history; the sequence that built your prod database is the sequence everyone replays.

**Rule two: applied migrations are immutable.** Once V2 has run somewhere, you must never edit `V2__...sql`. This is what the checksum enforces - Flyway hashes each migration file and stores it. If you change an already-applied file, the checksum no longer matches what's recorded, and Flyway stops with a validation error before touching anything.

```text
ERROR: Validate failed: Migrations have failed validation
Migration checksum mismatch for migration version 2
-> Applied to database : 1190837465
-> Resolved locally    : -2003918277
```

*What just happened:* someone edited a migration that had already run on this database. Flyway refused to continue, because it can't know whether the database reflects the old file or the new one. The fix is never to silently force it - it's to make your change a *new* migration (V4) instead of mutating an old one. This error is Flyway protecting you from drift, not getting in your way.

**Rule three: a migration that hasn't run yet is fair game.** Until V4 has been applied anywhere, you can edit it freely - it's still a draft on your branch. The immutability rule only kicks in once a migration has actually run against a database.

> Internalize this one line and most of Flyway makes sense: **the past is read-only, the future is yours to write.** You change the schema by adding the next file, never by rewriting an old one.

## Why this beats hand-run scripts

You could, in principle, keep a folder of SQL files and a discipline of running them carefully by hand. People do. It falls apart because humans forget which ones ran, run them out of order, run one twice, or skip the one that was added while they were on vacation. The history table and the three rules take all of that off your plate - Flyway is the discipline, enforced.

> **In the wild:** Flyway is effectively the default schema-migration tool in the JVM ecosystem. Spring Boot detects it on the classpath and runs your migrations automatically at application startup, so the app and its schema deploy as one unit. But Flyway is also a standalone command-line tool that works with plain SQL and any supported database - you do not need Java or Spring to use it, and this guide uses it that way.

If you want the broader picture of why schema migrations exist as a discipline across all tools and languages, see [/guides/database-migrations]. If you're coming at this from an ORM and wondering how Flyway relates, [/guides/how-an-orm-works] is worth a look - many ORMs generate migrations that a tool like Flyway then applies.

```quiz
[
  {
    "q": "What does Flyway use to know which migrations have already been applied to a given database?",
    "choices": ["The filenames on disk alone", "A row per migration in the flyway_schema_history table inside that database", "A lockfile committed to your repo", "Environment variables set at deploy time"],
    "answer": 1,
    "explain": "Flyway records each successful migration as a row in flyway_schema_history inside the target database, then reads that table to decide what still needs to run."
  },
  {
    "q": "You already ran V2 in production. You now need a different column type. What do you do?",
    "choices": ["Edit V2 and re-run it", "Delete the V2 history row and re-run V2", "Write a new migration, V3, that alters the column", "Force a checksum repair and edit V2"],
    "answer": 2,
    "explain": "Applied migrations are immutable. The past is read-only; you change the schema by adding the next versioned migration, not by editing an old one."
  },
  {
    "q": "Which filename is a valid Flyway versioned migration?",
    "choices": ["V3_add_index.sql", "v3-add-index.sql", "V3__add_index.sql", "migration_3.sql"],
    "answer": 2,
    "explain": "A versioned migration is V, the version, two underscores, a description, then .sql. A single underscore or a different prefix is not valid."
  }
]
```


---

# The Everyday Loop

Phase 1 was the mental model. This is the muscle memory: the small set of commands and habits you'll use every day. The good news is that there are really only a handful, and once you've done the loop two or three times it stops feeling like a tool and starts feeling like saving a file.

The loop is: write a migration file, ask Flyway what's pending, apply it, confirm. That's it. Let's walk it end to end.

## Pointing Flyway at your database

Flyway needs to know three things: where your database is, how to log in, and where your migration files live. With the command-line tool that goes in a config file, conventionally `flyway.conf`:

```text
flyway.url=jdbc:postgresql://localhost:5432/shop
flyway.user=shop_app
flyway.password=devsecret
flyway.locations=filesystem:./db/migration
```

*What just happened:* `url` is a JDBC connection string (the `jdbc:postgresql://...` shape works for Postgres; MySQL, SQL Server, and others have their own). `locations` tells Flyway which folder to scan for migrations - `filesystem:` for a directory on disk. Put real passwords in environment variables or a secrets store for anything but local play; the config file is the same idea regardless of where the value comes from.

> **For builders:** every config key has an environment-variable and command-line-flag equivalent (`FLYWAY_URL`, `-url=...`). In CI you'll usually set them as environment variables rather than committing a config file with credentials in it.

## Step 1: write the migration

A migration is plain SQL. No special syntax, no Flyway-specific dialect - whatever your database understands, you write. The only Flyway part is the filename.

```sql
-- V1__create_users.sql
CREATE TABLE users (
    id         BIGSERIAL PRIMARY KEY,
    email      TEXT NOT NULL UNIQUE,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

*What just happened:* this is the first migration. It's ordinary DDL. When Flyway runs it, the SQL goes straight to your database, and on success Flyway writes a `version = 1` row into `flyway_schema_history`. Nothing about the SQL itself knows Flyway exists.

## Step 2: see what's pending with `info`

Before you change anything, `flyway info` shows you the plan - what's applied, what's pending, in what order. Make this your reflex; it's the read-only "what would happen" view.

```console
$ flyway info

+-----------+---------+---------------+------+---------------------+---------+
| Category  | Version | Description   | Type | Installed On        | State   |
+-----------+---------+---------------+------+---------------------+---------+
| Versioned | 1       | create users  | SQL  | 2026-06-28 09:14:02 | Success |
| Versioned | 2       | add email idx | SQL  |                     | Pending |
+-----------+---------+---------------+------+---------------------+---------+
```

*What just happened:* V1 already ran (it has an install date and `Success`). V2 exists on disk but hasn't run yet - `State: Pending`, no install date. Flyway is telling you exactly one migration is waiting. No surprises, no guessing.

## Step 3: apply with `migrate`

`flyway migrate` is the verb that does the work. It reads the history table, finds everything pending, and applies it in order.

```console
$ flyway migrate

Successfully validated 2 migrations (execution time 00:00.041s)
Current version of schema "public": 1
Migrating schema "public" to version "2 - add email idx"
Successfully applied 1 migration to schema "public" (execution time 00:00.018s)
```

*What just happened:* Flyway saw the schema was at version 1, found V2 pending, ran it, and recorded a new history row. The schema is now at version 2. Run `migrate` again right now and it does nothing - there's nothing pending - which is exactly why it's safe to run on every single deploy. Re-running is a no-op, not a duplicate.

> The fact that `migrate` is safe to run repeatedly is called *idempotence*, and it's the property that lets you wire Flyway into automated deploys without fear. "Apply whatever isn't applied yet" is the only thing it ever does.

## Repeatable migrations: for things you want to re-run

Versioned migrations (`V...`) run exactly once. But some database objects you'd rather *redefine* every time they change - views, stored procedures, functions. You don't want a new `V` file each time you tweak a view's definition. That's what repeatable migrations are for. They use the prefix `R` and have **no version number**:

```sql
-- R__active_users_view.sql
CREATE OR REPLACE VIEW active_users AS
SELECT id, email
FROM users
WHERE last_login_at > now() - INTERVAL '30 days';
```

*What just happened:* an `R` migration runs after all pending versioned ones, and it re-runs whenever its checksum changes - that is, whenever you edit the file. So you keep one canonical file for the view, edit it in place, and Flyway re-applies it on the next `migrate`. Notice the `CREATE OR REPLACE`: repeatable migrations must be written to be safe to run again, because that's the entire point of them.

```text
R__active_users_view.sql   ← no version, re-runs when its contents change
V5__backfill_logins.sql    ← versioned, runs exactly once, ever
```

*What just happened:* the two kinds coexist in the same folder. Use `V` for one-time, ordered changes (creating tables, altering columns, backfilling data). Use `R` for definitions you maintain as living files (views, procedures, functions). Versioned migrations always run before repeatable ones in a given `migrate`.

## The full loop, one more time

Put together, your everyday rhythm looks like this:

```console
$ # 1. you create db/migration/V3__add_orders.sql in your editor
$ flyway info      # 2. confirm V3 shows as Pending
$ flyway migrate   # 3. apply it
$ flyway info      # 4. confirm V3 now shows Success
```

*What just happened:* write, inspect, apply, confirm. The `info` calls bracketing `migrate` aren't required, but they turn "I hope that did what I think" into "I watched it do exactly what I expected." Commit the migration file alongside the code that needs it, and every teammate and every environment gets the same change by running the same `migrate`.

> **In the wild:** in a Spring Boot service you rarely type `flyway migrate` at all - Boot runs it for you at startup, so the act of deploying the new app version *is* the act of applying its migrations. The command-line loop here is what's happening under the hood, and it's still how you'd drive Flyway in CI, scripts, or any non-Spring stack.

```quiz
[
  {
    "q": "What is the difference between a V migration and an R migration?",
    "choices": ["V runs on Postgres, R runs on MySQL", "V runs once in version order; R has no version and re-runs whenever its contents change", "V is for data, R is for schema", "There is no difference; both run every time"],
    "answer": 1,
    "explain": "Versioned (V) migrations run exactly once in order. Repeatable (R) migrations have no version and re-apply whenever their checksum changes - ideal for views and procedures."
  },
  {
    "q": "You run flyway migrate, it succeeds, then you immediately run it again. What happens?",
    "choices": ["It re-applies the last migration", "It errors because nothing is pending", "It does nothing, because there is nothing pending to apply", "It drops and rebuilds the schema"],
    "answer": 2,
    "explain": "migrate applies only what is pending. With nothing pending it's a no-op, which is why it's safe to run on every deploy - that's idempotence."
  },
  {
    "q": "Which command shows you what is applied and what is pending, without changing anything?",
    "choices": ["flyway migrate", "flyway info", "flyway clean", "flyway baseline"],
    "answer": 1,
    "explain": "flyway info is the read-only status view: each migration's version, description, and State (Success or Pending). migrate is the one that actually applies changes."
  }
]
```


---

# Production Reality

Everything up to now assumed a clean world: an empty database, a tidy folder of migrations, every command succeeding. Production is not that world. You'll adopt Flyway on a database that's already years old. A migration will blow up halfway through at 2am. Someone will ask you to "roll back" and you'll have to explain why that word doesn't mean what they think. This phase is about those moments, because how a tool behaves when things go wrong is what actually decides whether you trust it.

## Adopting an existing database: baseline

Here's the most common first stumble. You introduce Flyway to a database that already has fifty tables in it, built up over years by hand. You write `V1__...sql` to create your first new table and run `flyway migrate`. Flyway looks at the empty history table, concludes nothing has ever run, and tries to apply V1 from scratch - against a database that already has those objects. Chaos.

The fix is **baselining**: telling Flyway "this existing database is the starting line; consider everything up to this version as already done."

```console
$ flyway baseline -baselineVersion=1 -baselineDescription="legacy schema"

Creating Schema History table "public"."flyway_schema_history" ...
Successfully baselined schema with version: 1
```

*What just happened:* Flyway created its history table and inserted a single special row marking version 1 as the baseline - a `Baseline` type row, not a real migration. From now on Flyway treats versions at or below the baseline as pre-existing and only applies migrations *above* it. So you'd name your first real change `V2__...` and up. The years of hand-built schema stay exactly as they are; Flyway picks up the story from here.

```text
+-----------+---------+---------------+----------+---------+
| Category  | Version | Description   | Type     | State   |
+-----------+---------+---------------+----------+---------+
| Baseline  | 1       | legacy schema | BASELINE | Baseline|
| Versioned | 2       | add tax field | SQL      | Pending |
+-----------+---------+---------------+----------+---------+
```

*What just happened:* version 1 is the baseline marker (the old hand-built schema), and V2 is your first Flyway-managed change, pending. You only baseline once, when you first adopt Flyway on a populated database.

## When a migration fails mid-flight

A migration runs real SQL, and real SQL fails - a typo, a constraint violation, a column that already exists. What Flyway does next depends heavily on your database, and this is the single most important production detail to understand.

```console
$ flyway migrate

Migrating schema "public" to version "4 - add status column"
ERROR: Migration V4__add_status_column.sql failed
SQL State  : 42701
Error Code : 0
Message    : ERROR: column "status" of relation "orders" already exists
```

*What just happened:* V4 failed. The crucial question is what state your database is in now, and the answer is: it depends on whether your database supports **transactional DDL**.

- **Postgres** wraps DDL in transactions. A failed migration is rolled back as a unit - the database is left as if V4 never ran, and no failed row is recorded. You fix the SQL and re-run. Clean.
- **MySQL, Oracle** (older versions) do *not* fully support transactional DDL. A migration that does three `ALTER`s and fails on the third leaves the first two applied. The database is now half-migrated, and Flyway records a failed entry it won't run past until you sort it out.

> This is not a Flyway quirk - it's a property of your database engine, and Flyway is upfront about it. The practical takeaway: on databases without transactional DDL, **keep each migration small and ideally single-statement**, so "it failed halfway" has the smallest possible blast radius.

When you do end up with a recorded failure on a non-transactional database, you fix the SQL (or manually undo the partial work) and then run `flyway repair`:

```console
$ flyway repair

Repaired failed migration entry for version 4.
```

*What just happened:* `repair` cleans up the failed-migration bookkeeping in the history table so Flyway will attempt the (now-corrected) migration again on the next `migrate`. `repair` also re-syncs checksums for migrations whose files legitimately changed. It does **not** touch your actual data - it only fixes Flyway's records of what happened.

## The rollback truth: forward-fix, not undo

Now the conversation everyone eventually has. A bad migration went out. Someone says "roll it back." Here's the reality you need to hold firmly: **Flyway does not give you a free, automatic undo, and you should be suspicious of any tool that claims to.**

The reason isn't laziness - it's that a true undo is often impossible. If V5 ran `DROP COLUMN phone_number`, the data in that column is gone. No `undo` script can conjure it back. If V6 transformed a million rows, reversing the transform may not be lossless. The forward direction destroyed information the backward direction would need.

So the production-grade answer is the **forward fix**: when a migration causes a problem, you don't rewind history - you write the *next* migration that corrects it.

```sql
-- V5__add_status_to_orders.sql  (the change that caused trouble)
ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'unknown';

-- V6__fix_status_default.sql  (the forward fix, a NEW migration)
ALTER TABLE orders ALTER COLUMN status SET DEFAULT 'pending';
UPDATE orders SET status = 'pending' WHERE status = 'unknown';
```

*What just happened:* V5 shipped a bad default. Instead of editing V5 (forbidden - it already ran) or trying to undo it, you ship V6 to correct it. The history table now reads V5 then V6, which is the literal truth of what happened to the database. This keeps every environment converging and keeps your migration history an accurate, append-only log. (Flyway's commercial editions do offer scripted `U` undo migrations, but even there you write the reversal SQL yourself - there is no magic.)

> Design migrations to make forward-fixing easy: prefer additive changes, and split risky changes into stages. To remove a column safely, first stop writing to it (one deploy), then drop it in a later migration once you're sure nothing breaks - so a bad step is always recoverable by *not proceeding*, rather than by reversing.

## The one command to fear: clean

Flyway has a command called `flyway clean`. It drops every object in the configured schema - tables, data, everything - leaving it empty.

```console
$ flyway clean
Successfully dropped pre-schema database level objects (...)
Successfully cleaned schema "public" (execution time 00:00.213s)
```

*What just happened:* your schema is now empty. Gone. `clean` is genuinely useful in disposable environments - wiping a local dev database or a CI database between test runs to start fresh. It is also a loaded gun pointed at production. Modern Flyway ships with `flyway.cleanDisabled=true` by default for exactly this reason. **Leave clean disabled everywhere except throwaway databases, and never give a production connection a path to it.**

## Putting it together

Production Flyway is mostly three disciplines. Baseline once when you adopt it on an existing database. Keep migrations small so a mid-flight failure on a non-transactional database has a tiny blast radius, and reach for `repair` to clean up the bookkeeping when one does fail. And treat history as append-only: you fix forward with a new migration, you never rewind. Do those three things and Flyway stops being a tool you wrestle and becomes the boring, reliable layer it's meant to be - the one part of your deploy you stop worrying about.

For the wider why behind these patterns - expand/contract migrations, online schema changes, zero-downtime deploys - [/guides/database-migrations] goes deeper on the strategy that applies no matter which migration tool you use.

```quiz
[
  {
    "q": "You're adopting Flyway on a database that already has dozens of hand-built tables. What's the first step?",
    "choices": ["Run flyway clean to start fresh", "Run flyway baseline to mark the existing schema as the starting version", "Write V1 to recreate every existing table", "Run flyway repair"],
    "answer": 1,
    "explain": "baseline records the existing schema as the starting line so Flyway only applies migrations above the baseline version, leaving the legacy schema untouched."
  },
  {
    "q": "A migration shipped a bad default. What's the production-correct way to fix it?",
    "choices": ["Edit the original migration and re-run it", "Run an automatic Flyway undo", "Write a new, higher-versioned migration that corrects the problem", "Delete the migration's history row"],
    "answer": 2,
    "explain": "Applied migrations are immutable and true undo is often impossible (dropped data is gone). You fix forward with a new migration, keeping history an accurate append-only log."
  },
  {
    "q": "Why does a failed migration leave a Postgres database clean but can leave a MySQL database half-migrated?",
    "choices": ["Flyway behaves differently per vendor by choice", "Postgres supports transactional DDL so a failed migration rolls back as a unit; older MySQL does not", "MySQL ignores the history table", "Postgres runs migrations twice for safety"],
    "answer": 1,
    "explain": "Transactional DDL is a database-engine property. Postgres rolls a failed migration back atomically; engines without it can leave partial changes, so keep those migrations small."
  }
]
```
