Upgrading

Talks about:

What each release needs from you, when it needs anything. Releases not listed here are drop-in.

2026.9.8

Converters are generated beside your repositories

converter.mapConverterClass used to default to com.example.persistence.converter.ToMapConverter, and every generated converter is written to the package that setting names. A project that set repositories.basePackageName and nothing else therefore had its repositories generated where it asked and its converters generated into the example’s package — a name it had never written down, reached through a setting it had never heard of.

The default is now the bare name ToMapConverter, and a name without a package is resolved against repositories.basePackageName. So converters land in <your base package>.converter, and a project that already worked around this by writing the setting out in full keeps exactly what it had — a fully-qualified name is still followed to the letter.

Nothing to do unless you configured mapConverterClass solely to relocate the converters. That setting can go:

<converter>
  <mapConverterClass>com.example.store.converter.ToMapConverter</mapConverterClass>
</converter>

If you want the converters to stay where they are while your base package differs, keep it. Anything of your own importing a generated converter moves with it — that shows up as a compile error naming the import, not as anything silent.

A schema.sqlStatementsDirectory holding versioned migrations is now read in version order. Previously the files were read in name order, so V10__ and V12__ were applied ahead of V2__ and every column added past the ninth migration was missing from the schema YoSQL worked from.

Nothing needs changing, but two things are worth rechecking if you pointed this at a Flyway or Liquibase directory. Schema validation lowered to WARN because it reported columns that plainly exist can go back to ERROR. Parameter and result types written out by hand because inference “did not work” are now inferred, and the front matter still wins where the two disagree — so a type declared against the wrong schema stays wrong until you delete it.

A column is also read in the spelling its own DDL was written in. Marking a schema file with a vendor now decides how its column types are read, so bytea, timestamptz, bigserial, jsonb and the rest of a database’s own spellings reach statements that declare no vendor themselves:

-- vendor: postgresql

create table attachment (id bigserial primary key, payload bytea not null);

Previously those spellings were only read when the statement declared the vendor, which a project with one database has no reason to do — so its schema answered for the standard types and nothing else. Nothing changes for a project that marks its statements, or for one that marks neither.

Whether marking the schema gains you anything depends on how your DDL is written, and it is worth checking rather than assuming. uuid, text, varchar, boolean, integer, bigint, numeric, date and timestamp with time zone are read the same by every database, so a schema written in those types already types completely, and a vendor adds nothing to it. A schema saying timestamptz, bigserial, bytea, jsonb or citext is the one this is for. The build says which columns it cannot type , which answers it for your schema without guessing.

Where the schema is a migration directory, the files are checksummed by the tool that applied them and a comment added to one already run makes it refuse to start. The new schema.vendor setting says the same thing from the build instead:

<schema>
  <sqlStatementsDirectory>src/main/resources/db/migration</sqlStatementsDirectory>
  <vendor>PostgreSQL</vendor>
</schema>

A schema file naming its own vendor keeps it.

Undo migrations are no longer read as schema

A Flyway undo migration — U48__… beside the V48__… it reverses — says how to take a migration back out. It is never run by a migration, so the database has never had it applied, and reading it describes a schema that does not exist. It also carries no version this reader recognised, so it sorted after every versioned migration and undid them last of all.

What that looked like was a column missing from an otherwise perfect schema: U48’s drop column is something the reader follows exactly, so it took the column back out and reported nothing. If a statement of yours was reported as using a column that plainly exists, and the migration adding it has an undo beside it, that was this.

A camelCase parameter finds its snake_case column

:accountId now takes its type from an account_id column, the same conversion a result row component has always had. Previously a parameter had to be spelled exactly as the column was, so every parameter named after a snake_case column fell through to “no type known for” and had to be declared by hand — the schema had the answer and the name never reached it. If your front matter is full of parameters: blocks that only repeat what the schema says, they can go.

A parameter written the column’s way keeps working, and a type in the front matter still wins over both.

A write holding a collection no longer asks for a batch

executeBatch defaults to on for writing statements, and a collection parameter cannot be batched — each of its values needs a placeholder of its own, so every execution would need a different query. Those two defaults met in a build failure on every update … where state in (:states), curable only by writing executeBatch: false on each one. The default now yields: a statement holding a collection is generated without a batch method. Writing executeBatch: true on one is still an error, because that asks for something that cannot exist.

Two generated types with one name stop the build

A repository interface is the repository’s name without its Repository suffix, so statements in a document directory generate an interface called Document alongside DocumentRepository. A resultRowType naming <your base package>.persistence.Document names that same type, and the two were written to one file — whichever the generator reached second was the one that survived.

What that looked like was a compile error on a file you did not write, saying the type a converter instantiates is abstract, or a duplicate class where the record was your own. Now it stops generation and names both:

Two generated types would both be written as 'com.example.persistence.Document': an interface
and a record.

The same name can also meet a class you wrote yourself, which is the likelier of the two: a project keeping its stores in the repositories’ package has hand-written classes sitting exactly where the generator writes, and a windDown statements directory generates an interface called WindDown. That was a duplicate class from javac, in whichever of the two files it reached first. It is now the same kind of failure, before anything is written, naming the file it met.

Either way this only affects a build that was already failing at compile time, so nothing that worked can start failing. If you see it, rename whichever of the two names is yours, move it to another package, or turn off generateInterfaces .

A parameters: block no longer changes the order of a method

A generated method takes its parameters in the order the statement binds them. Declaring some of them used to move those to the front:

-- parameters:
--   tenantId: uuid
select id from restore where id = :id and tenant_id = :tenantId
findWithdrawableRestore(UUID tenantId, UUID id)   // before
findWithdrawableRestore(UUID id, UUID tenantId)   // now, and what the SQL says

Check any statement whose block names fewer parameters than the statement binds, or names them in an order the SQL does not use. Where the moved parameters have different types, your call sites stop compiling and the compiler shows you every one. Where they share a type — two UUIDs, as above — they compile before and after, and the arguments were going in the wrong way round before this release. That is the case worth looking for: a query returns nothing where it should return a row, and a write touches the wrong row.

A block naming every parameter in the order the SQL binds them is unaffected, which is how most are written.

The build says which columns it cannot type

Alongside the table count, a run now names the columns the schema holds and cannot give a Java type:

The schema holds 1 column(s) whose type YoSQL does not map: document.payload (jsonb). No vendor
is declared, and the types only one database has are looked up only for a declared one — so
'schema.vendor' may be all that is missing.

The table count is the same whether or not a vendor is declared — it is the columns that change — so it was the wrong figure to read a vendor’s effect off. This is the one that answers it. Nothing to do: an untyped column that no statement selects and no parameter is named after costs nothing.

2026.8.8

The first release since 2023.5.3, and it changes enough to be worth reading before you bump the version.

Java 25 is now the minimum

Both to run YoSQL and to compile and run what it generates. Generated code uses var, text blocks, records and sequenced collections.

If your project is on an older Java release, stay on 2023.5.3 until you can move. There is no configuration that makes the generator emit older code — the switch that used to do that is gone, because it silently produced output that no longer matched what the documentation promised.

A converter is named by its class, and nothing else

The four-part converter description and the registry that held it are gone. A statement that used to name a converter by alias:

-- resultRowConverter:
--   alias: itemConverter

now names the class:

-- resultRowConverter: com.example.persistence.converter.ToItemConverter

and the build configuration that declared the alias goes away entirely. In Maven:

<converter>
    <!-- delete the whole rowConverters block -->
    <rowConverters>
        <rowConverter>
            <alias>itemConverter</alias>
            <converterType>com.example.persistence.converter.ToItemConverter</converterType>
            <methodName>asUserType</methodName>
            <resultType>com.example.domain.Item</resultType>
        </rowConverter>
    </rowConverters>
</converter>

The same applies to rowConverters in Gradle and Ant, and to --rowConverters on the command line. defaultConverter survives but now takes a single class name rather than the four parts.

YoSQL reads the class to find its one public method taking a ResultSet. That method’s name is what the repository calls and its return type is what the statement produces, so nothing repeats what the class already says. Two consequences worth knowing:

The field the repository holds the converter in is now the class name with a lower-case first letter — toItemConverter rather than whatever the alias was. That only matters if you were reading generated fields directly.

Generic types cannot be result rows

A record declaring type parameters is refused rather than mapped, because a statement says nothing about what to substitute for them. Name a concrete type instead.

YoSQL reads your schema now

If your project keeps create table statements where YoSQL can see them, this release starts holding the rest of your SQL to them: a column that does not exist, a parameter whose type disagrees with its column, a nullable column read into a primitive. Nothing connects to a database, so it works in a checkout with no services running.

It reports and does not stop. The default is WARN, so no build that passed before fails because of this — expect new warnings, not new failures. Read them; they are the queries that would have failed on whichever request reached them first.

Once you have dealt with them, turn it up so nothing new gets in:

<schema>
  <validation>ERROR</validation>
</schema>

To hear nothing at all, set it to OFF.

It also settles what a parameter’s type is, which removes the parameters block from write statements entirely — see schema validation .

A colon with no name after it is no longer a parameter

:id is a parameter. A bare : is not, and used to be read as one — which made two ordinary things wrong.

A statement carrying a licence header picked up a parameter with no name from the colon in SPDX-License-Identifier:. And PostgreSQL’s :: cast bound two parameters, the bare colon and the type name, so every parameter after a cast was bound to the wrong placeholder — the statement ran and answered with the wrong rows.

If you have statements using :: casts, their generated methods change: the spurious parameters disappear and the real ones move to the indices they should always have had. Check any such method’s signature after upgrading, and be glad if you never hit the bug.

Every statement is now reachable with and without a connection

Where a statement got its connection used to be decided per statement by createConnection, and you got one method. It is now decided by the caller, and you get two:

Optional<Tenant> findTenant(UUID id);
Optional<Tenant> findTenant(Connection connection, UUID id);

The first opens a connection from the repository’s DataSource and closes it; the second runs on the one it is given. That is what lets several statements share a transaction — see transactions .

Nothing you call today changes name or signature, so existing code keeps compiling, with one exception: a repository whose statements all set createConnection: false used to have a no-arg constructor and now takes a DataSource, because it now also has methods that need one. Pass it one, or set generateConnectionOverloads to false to keep one method per statement.

If you wrote a statement twice in a file — once plain and once with createConnection: false — to get both shapes, delete the second one. It now generates a method with a 2 in its name for no reason.

A parameter with no type now fails instead of becoming an Object

A parameter the front matter did not type used to be bound as java.lang.Object. The method compiled, accepted anything, and offered exactly the type safety of the JDBC it replaced — silently. It is now a build error naming the file, the statement and every parameter still without a type.

Two things fill them in. A statement naming a record with resultRowType takes each parameter’s type from the component of the same name, so most read statements need nothing:

-- name: findTenant
-- returning: single
-- resultRowType: com.example.domain.Tenant
select id, slug from tenant where id = :id

Tenant declares UUID id, so :id is a UUID. For everything else — write statements, above all — name the types. The front matter now takes a mapping of name to type alongside the list form:

-- parameters:
--   id: uuid
--   slug: string
--   createdAt: instant

uuid, string and instant are short names for the types statements are usually written in; the full list is under parameters . A fully-qualified class name still works everywhere, and the list form is unchanged for parameters that need a sqlType, a scale or a variant.

Where the build fails, the message shows the front matter to add. Nothing else has to change.

Statements that generate nothing now fail

A statement whose name matches none of the configured prefixes, and which sets no explicit type, used to be skipped without a word — so a typo removed a method from your repository and said nothing. It is now a build error naming the file and the statement. If a release starts failing here, it is reporting something that was already broken.

The TI logging API is gone

logging.api no longer accepts TI. The generator behind it was never written: it answered every question with an empty block while reporting that logging was off, and the repositories it produced did not compile — a bare if () { reading a variable nothing declared.

If your build sets it — <api>TI</api> in a pom.xml, api = 'TI' in a build.gradle, or --logging-api=TI on the command line — pick one of NONE, JUL, SYSTEM, LOG4J, SLF4J or TINYLOG. NONE is the closest to what TI actually did, which was nothing.

The java configuration group is gone

Its six switches decided whether generated classes, fields, methods, parameters and locals were declared final. Generated code reassigns none of them, so they all are now, which is what the defaults already said. One of them, useSealedInterfaces, was documented in every frontend and did nothing at all.

Remove the block if your build has one — a <java> element in a pom.xml, a java { } block in a build.gradle, a <java> element in an Ant task, or --use-final-* on the command line. Maven and Ant fail on an unknown element, so this one is not optional. Nothing about the generated code changes unless you had turned one of them off, in which case the output gains the final keywords it describes.

The names configuration group is gone

Its twenty-two settings renamed the variables inside generated methods — the Connection, the ResultSet, the loop counter. None of them is part of a repository’s API, so none of them was a decision worth making: a name nobody outside the method can see cannot fit a codebase better or worse. They also had to be kept distinct from each other, which is why a whole validator existed to check they were.

Remove the block if your build has one — a <names> element in a pom.xml, a names { } block in a build.gradle, a <names> element in an Ant task, or the matching command-line options. Maven and Ant fail on an unknown element, so this one is not optional. Generated code is unchanged unless you had renamed something, in which case it goes back to the default name.

The one thing those settings could rescue was a statement whose own parameter is called connection, statement, resultSet, index or another name a generated method already uses. That is now a build error naming the file, the statement and the parameter, instead of a Java error about a variable already defined in a file you did not write. Rename the parameter in the SQL — the name reaches no further than the method’s signature.

skipLines is gone, and a licence header is dropped on its own

A .sql file that opens with a block comment has it dropped, however many lines it runs to. The count that used to have to match — and to be kept matching every time somebody edited the header — is no longer a setting.

Remove skipLines from your build; Maven and Ant fail on an unknown element. If your headers are written as -- lines rather than as a block comment, rewrite them as /* … */. A -- line at the top of a file is front matter, and -- SPDX-License-Identifier: 0BSD is as good a YAML mapping as -- name: findTenant, so there is no way to tell the two apart.

Generated code now says so, and the annotations switches are gone

Every generated class, field and method carries @Generated:

@Generated(
    value = "wtf.metio.yosql",
    comments = "generated by YoSQL 2026.8.8 - do not modify, this file is rewritten on every build"
)

It used to be off unless you turned it on, which is backwards for a marker whose whole job is to tell coverage tools, linters and the next reader that nobody wrote this by hand. Eleven settings decided whether it appeared, which annotation class it used, which members it carried and what each of them said; all eleven are gone, and so is the javax.annotation.Generated option, which has not existed in the JDK since Java 11.

The value is the generator’s fully qualified name, which is what the annotation’s own documentation asks for. The comments carry the release that wrote the file — the one fact about generated code that is nowhere else in it, since which .sql file a method came from is already in its javadoc.

There is deliberately no date. Two builds of the same statements produce the same code, and a timestamp would be the only thing in the output that differed between them.

javax.annotation.processing.Generated lives in the java.compiler module, which a classpath build always has. A modular project compiling generated code inside its own module needs requires static java.compiler; in its module-info.java — static, because the annotation is discarded after compilation and nothing needs it at run time.

Remove the <annotations> switches from your build — annotationApi, annotateClasses, annotateFields, annotateMethods, classMembers, fieldMembers, methodMembers, classComment, fieldComment, methodComment and generatorName. Maven and Ant fail on an unknown element. The three settings that add your own annotations to generated repositories, constructors and methods are unchanged.

Repository and method names are no longer spelled out in configuration

Eight settings decided what to put around a generated name — repositoryNamePrefix, repositoryNameSuffix, repositoryInterfacePrefix, repositoryInterfaceSuffix, executeOncePrefix, executeOnceSuffix, executeBatchPrefix and executeBatchSuffix. Six of them defaulted to nothing at all, and the two that did something were carrying the two names that have to differ from each other:

That is what the defaults already produced. Remove the settings from your build and from any front matter that set them per statement; Maven and Ant fail on an unknown element. If you had renamed something, the generated names change back — name the repository with repository in the front matter, which still decides it outright.

sqlStatementsDirectory is relative to your project, not to where the build was started

sqlStatementsDirectory says where the DDL describing your schema lives. Written as a relative path, it used to be resolved against whatever directory the build was running in rather than against the module being built — so in a multi-module project, mvn verify from the root looked for the schema under the root while mvn verify inside the module found it. Every frontend resolves it against the project now, the same as every other directory setting.

Nothing reports the difference, which is what makes it worth checking: a schema directory that resolves to nothing is indistinguishable from a schema that raises no complaints, because reading the schema is designed never to fail a build. If you set this to a relative path, confirm it is relative to the module’s own directory. An absolute path is unaffected.

Maven is where this is most likely to have bitten you. The CLI runs in the directory you invoke it from, which is the answer it now arrives at deliberately; Gradle ran in the project’s own directory, so it already found the right one, and no longer depends on that being true.

Ant can set the method name prefixes and the annotations

Ant builds an attribute setter only for a type it can make out of a string, and allowedCallPrefixes , allowedReadPrefixes and allowedWritePrefixes are lists — so the task declared them and then refused them, with doesn't support the "allowedReadPrefixes" attribute. The whole annotations group went the same way: the nested element could be written, and nothing inside it could be set.

Both work now. The three lists are one attribute, separated by commas:

<repositories validateMethodNamePrefixes="true"
              allowedReadPrefixes="fetch,find"/>

An annotation is a nested element, and each of its members is a nested element of that:

<annotations>
    <repositoryAnnotations type="jakarta.inject.Named">
        <member key="value" value="tenants"/>
    </repositoryAnnotations>
</annotations>

Nothing here can have broken a build: none of it could be set before. If you turned validateMethodNamePrefixes off because the prefix lists were out of reach, it is worth turning back on.

2023.5.3 and earlier

See the release notes for those versions.