Stock+All posts

Generating jOOQ code from Flyway migrations, without a database

Behzod Halil··7 min read

Key takeaways

  • jOOQ's DDLDatabase (from jooq-meta-extensions) parses your migration SQL directly and builds an in-memory schema, so code generation needs no server, no container, and no second declaration of the schema.
  • Set the sort property to flyway. The default ordering is lexical, and lexically V10 sorts before V2, so the tenth migration is where a build that worked for nine starts applying DDL in the wrong order.
  • Set defaultNameCase to lower for Postgres, which folds unquoted identifiers to lower case. Without it the generated names stop matching what the running database answers to.
  • The cost is a constraint on what you may write in a migration: every statement has to be something jOOQ's parser understands. Across 54 migration files here there is no CREATE EXTENSION, no DO $$ block, and no function or trigger.
  • This approach generates what Flyway would produce, not what production has. A live-database setup can see drift between the two; this one cannot, and that is the one honest argument for the alternative.
  • Measured on 27 August 2026 with no Postgres installed and the Docker daemon stopped: 923 ms of generation, 164 files, 53 tables.
  • Reading the migration directory is exactly as good as the migration directory. Two branches can each add a V53, both diffs are clean, and Flyway then refuses to boot. A twenty-line test over the whole directory catches it; reviewing the diff cannot.

jOOQ generates its code from a schema, which means something has to be holding a schema at build time. The usual answer is a database, and a database is the one thing our CI does not have.

StockPlus runs a Spring Boot backend where every query is written in the jOOQ DSL, so nothing on the server compiles until the generator has produced its classes. The GitHub Actions workflow has no services: block, no Docker, and no Postgres. This is how the schema gets there anyway, which property in the config is the one to read twice, and the single migration mistake that no build configuration can catch.

Where we stood

Three facts, and the shape of the problem falls out of them.

So the schema is right there in a directory, and the question is only whether the code generator can be made to read it. What we ended up with is one directory read by two different things at two different times:

The migration directory feeds two readers. At build time, DDLDatabase passes the SQL through the jOOQ parser to produce 164 generated files covering 53 tables. At runtime, Flyway applies the same files to produce the Postgres schema.
One directory, two readers. Neither of them needs the other to have run.

Four ways to hand jOOQ a schema, and why three of them lose

Point the generator at a live database. This is what most jOOQ setups do and it is the path of least resistance. It also makes the build depend on a machine. Whoever ran migrations last decides what your generated API looks like; a colleague sitting on an older branch generates a different one; CI needs a service container and a wait-for-healthy step. Worst of all, drift is silent in the direction that hurts: apply a migration by hand, never commit it, and codegen cheerfully generates the column while the repo has no record of it.

Start a container, migrate, generate, tear it down. Testcontainers does this well and it is the right answer for a lot of projects, because a real Postgres accepts anything Postgres accepts. It needs a Docker daemon wherever the build runs, which our runner does not have, and it puts a container start on the path of every compile rather than once per CI run.

Declare the schema a second time, as entities. The JPA answer, and it is ruled out here for a reason that has nothing to do with taste: two descriptions of one schema drift, and the one that drifts is never the one you are looking at.

The fourth option is the one that fits. jooq-meta-extensions ships DDLDatabase, which runs DDL through jOOQ’s own SQL parser and builds an in-memory schema out of the result. No server, no container, and no second declaration — it reads the same files Flyway applies. jOOQ documents it under code generation from DDL files, and the Gradle side is the gradle-jooq-plugin.

The configuration is about ten lines

jooqGenerator("org.jooq:jooq-meta-extensions:${libs.versions.jooq.get()}")

// ...

database.apply {
    name = "org.jooq.meta.extensions.ddl.DDLDatabase"
    properties = listOf(
        Property().withKey("scripts")
            .withValue("src/main/resources/db/migration/*.sql"),
        Property().withKey("sort").withValue("flyway"),
        Property().withKey("defaultNameCase").withValue("lower"),
    )
}
Kotlin — server/build.gradle.kts

scripts is a glob over the Flyway directory. Note what is not there: a copy, an export, a dump. It is the same directory Flyway reads at runtime, so there is no second artifact to keep in sync and no way for the generated code to describe a schema the application will not actually get.

sort is the one to read twice. It decides the order the scripts are applied in before the schema is assembled, and the default is lexical. Lexically, V10 sorts before V2. Apply DDL in that order and a migration that alters a table runs before the migration that creates it. The failure itself is loud; the cause is not, because everything worked for nine migrations and the tenth is where it starts. Setting it to flyway parses the version out of the filename and orders the way Flyway does.

defaultNameCase matters because Postgres folds unquoted identifiers to lower case. Leave it and the parsed schema keeps whatever case the DDL was typed in, so the generated constants stop matching the names the running database will answer to. All three are documented among the code generation configuration options, and the version scheme sortis parsing is Flyway’s own, from the Flyway documentation.

A config property that looks cosmetic in a code generator is usually encoding an assumption about ordering or identifiers. Those are the two places where a build can be correct on every input you have tried so far.

What it costs

This is a parser, not a database, and the difference shows up in three places.

It constrains what you may write in a migration. Every statement has to be something jOOQ’s parser understands. Across all 54 migration files here there is no CREATE EXTENSION, no DO $$ ... $$ block, and no function or trigger. That stopped being a coincidence the moment the build depended on it: reach for a Postgres-only construct now and the thing that tells you no is the code generator, not the database.

It generates what Flyway would produce, not what production has. If someone altered a table by hand in production, this setup cannot see it, because it never looks at production. A generator pointed at a live database can. That is the one honest argument for the option we rejected, and it is worth saying plainly rather than discovering later.

There is no data, and therefore no statistics. Anything in a generator that wants to inspect rows rather than structure has nothing to work with.

What it bought

MeasureResult
Database at build timenone
Docker in CInone
Generation, jOOQ's own timer923 ms
Full generateJooq, --rerun-tasks --no-daemon22.6 s wall clock
Generated output164 files, 53 tables
Descriptions of the schema in the repoone
Measured 27 August 2026, Apple M4, Gradle with no daemon, no Postgres installed and the Docker daemon stopped.

The 22.6 seconds is a cold, deliberately pessimistic number: no daemon and every task re-run. The 923 ms is what generation itself costs once Gradle has started, and it is the figure that matters, because generateSchemaSourceOnCompilation means you pay it often.

The one mistake this cannot catch

This is exactly as trustworthy as the migration directory itself. Nothing in the config above has an opinion about whether that directory makes sense, and there is one way for it to stop making sense that no amount of care prevents.

Two branches each add a V53. Both are individually correct, both pass review, both pass their own tests. Flyway refuses to start when two migrations share a version, and it throws during validate, before Spring’s context is up, so the application does not boot at all. The database is untouched, nothing is applied, and the deployment is dead.

It happened here on 25 August 2026: V53__institution_curation_identity.sql and V53__add_briefing_push_prefs.sql merged minutes apart.

The reason this needs more than discipline is that the collision is invisible in the change that creates it. Each diff is clean on its own. The conflict comes into existence at the moment the second branch merges, which is precisely when nobody is reading. Checking the highest version on main before merging does not help either: main can gain a migration between the check and the merge.

Treat for the curious

The fix is a test, not a process, because it has to look at the whole tree rather than at a diff.

private val migrationDir = File("src/main/resources/db/migration")

/** `V53__add_briefing_push_prefs.sql` -> `53`. */
private fun versionOf(file: File): String? =
    Regex("""^V(\d+(?:[._]\d+)*)__""").find(file.name)?.groupValues?.get(1)

@Test
fun `GIVEN the migration directory WHEN versions are grouped THEN none repeats`() {
    // GIVEN
    val migrations = migrationDir.listFiles { f -> f.name.endsWith(".sql") }.orEmpty()

    // WHEN
    val duplicates = migrations
        .mapNotNull { file -> versionOf(file)?.let { it to file.name } }
        .groupBy({ it.first }, { it.second })
        .filterValues { it.size > 1 }

    // THEN
    assertTrue(duplicates.isEmpty(), /* ... names the colliding files ... */)
}
Kotlin — FlywayMigrationVersionsTest.kt

The name carries the whole specification and the body markers stay bare, which is this repo’s convention rather than a flourish. A failing build prints that name, so the report says what broke without anyone opening the file.

It runs against whatever is actually on the branch, so a merge or a rebase that introduces the collision fails the build rather than the deploy. That is the entire trick: move the check from the diff, where the problem is invisible, to the directory, where it is obvious.

A second test in the same class catches the quieter sibling. A filename typo like V53_add_thing.sql, with one underscore instead of two, is silently ignored by Flyway rather than rejected. The migration never runs, the column it adds never exists, and the first thing you learn about it is a query failing in production.

If you copy this, decide what you want it to do about repeatable migrations. Our version regex returns null for an R__ file and the second test asserts that nothing is null, so adding a repeatable migration would fail the build with a message about an unparseable filename. There are none in this repo today, which is the only reason the two tests agree.

What generalises

None of this is really about jOOQ. It is about which artifact you let a build depend on, and preferring the one that is already versioned.

The client half of this app produced the same lesson in a different register: moving its push pipeline to OneSignal turned up a send that returned HTTP 200 while delivering nothing to anybody. A duplicate migration version and a successful-looking failed push are the same species of bug: the system had every chance to say so and did not.

The server described here backs StockPlus, an app for following congressional and insider filings, 13F holdings and price alerts on iOS and Android. The interesting parts of building it are mostly the parts you cannot see from a store listing, which is what this blog is for.

Frequently asked questions

Can jOOQ generate code without a database connection?

Yes. jooq-meta-extensions ships DDLDatabase, a code generation source that runs your DDL through jOOQ's own SQL parser and builds an in-memory schema from the result. You point it at a glob of .sql files, so if Flyway migrations already describe your schema, the generator can read the same files Flyway applies at runtime. No server, no container, and no second copy of the schema to keep in sync.

How do I generate jOOQ classes from Flyway migration files?

Add jooq-meta-extensions to the jooqGenerator configuration, then set the database name to org.jooq.meta.extensions.ddl.DDLDatabase and give it three properties: scripts pointing at your migration glob (for example src/main/resources/db/migration/*.sql), sort set to flyway, and for Postgres defaultNameCase set to lower. Generation then runs as an ordinary Gradle task with no external services.

What does the jOOQ DDLDatabase sort property do?

It decides the order the DDL scripts are applied in before the schema is built. The default is lexical, which is fine until your tenth migration, because lexically V10 sorts before V2 - so a migration that alters a table can be applied before the one that creates it. Setting sort to flyway parses the version number out of the filename and orders the way Flyway itself does. It looks like a cosmetic setting and it encodes an ordering assumption.

Is DDLDatabase better than using Testcontainers for jOOQ codegen?

It depends on what your migrations contain. Testcontainers runs a real Postgres, so any DDL the database accepts will work, and it can catch problems a parser cannot. It also needs a Docker daemon wherever the build runs and puts a container start on the path of every compile. DDLDatabase has neither cost but constrains you to SQL that jOOQ's parser understands. If your migrations use extensions, procedural blocks or vendor-specific DDL, use a container.

What happens when two Flyway migrations have the same version?

Flyway throws during validation with 'Found more than one migration with version N', before the Spring context is up, so the application fails to start. Nothing is applied, so the database is untouched, but the deployment is dead. It is a common accident because the collision is invisible in the change that creates it: two branches each add a V53, both are individually correct, and the conflict only exists after the second one merges.

How do I stop duplicate Flyway migration versions?

Not by checking the highest version on main before merging, because main can gain a migration between the check and the merge. A test that lists the migration directory, parses the version out of each filename and fails on any duplicate runs against whatever is actually on the branch, so a merge or rebase that introduces the collision fails the build instead of the deploy. It takes about twenty lines and no infrastructure.

Try it before you decide.

Free to start, no card required. One AI briefing each morning, a live watchlist, and alerts that actually matter.