Concise by design.
Fast by default.

Across this reproducible latency suite Storm takes five of twelve workloads outright, with no framework inside 3%. It is in the leading group on eleven and top three on all twelve, a consistency no other ORM matches. Its clearest margins are on the mapping-heavy joins.

Eight implementations run against the same database with identical schema, data, and transaction boundaries. Every result includes a real TCP round trip, and the source behind every number is open for inspection.

PostgreSQL 17 over TCP · JMH · Storm 1.14.0 · measured 2026-09-03

5 of 12workloads where Storm is fastest, with no framework within 3%.
11 of 12workloads where Storm is within 3% of the fastest, the band inside which a repeat run reorders the field.
Only ORMin the top three on every workload. No other ORM manages more than eight, and Storm's worst placing across the suite is third.

Performance results

The workloads cover common data-access paths: point reads, joined entity hydration, projections, keyset pagination, dynamic queries, batch and dependency-ordered writes, change-aware updates and one-to-many object graphs.

Eight implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. The chart plots every workload as a multiple of the hand-written JDBC baseline, so each line traces a framework's overhead across the twelve workloads. The chart opens with the primary key lookup and then orders the workloads by how far the field spreads from JDBC, keeping the three join sizes together, so overhead grows to the right and a flat line means the framework does not follow. Lower is faster; the dashed line is JDBC itself.

Time relative to hand-written JDBC

library ÷ JDBC · lower is faster
PK lookupprojectionmulti-stmtupdatedynamicgraph insertobject graphbatch insertkeysetjoin·10join·100join·1k Storm · PK lookup · 1.03× JDBC (193 µs)Storm · projection · 1.01× JDBC (980 µs)Storm · multi-stmt · 1.06× JDBC (671 µs)Storm · update · 1.04× JDBC (580 µs)Storm · dynamic · 1.24× JDBC (942 µs)Storm · graph insert · 1.09× JDBC (2.72 ms)Storm · object graph · 1.11× JDBC (1.19 ms)Storm · batch insert · 1.07× JDBC (3.64 ms)Storm · keyset · 1.15× JDBC (461 µs)Storm · join·10 · 1.05× JDBC (673 µs)Storm · join·100 · 1.21× JDBC (891 µs)Storm · join·1k · 1.34× JDBC (3.27 ms)Storm Hibernate · PK lookup · 1.08× JDBC (202 µs)Hibernate · projection · 1.00× JDBC (971 µs)Hibernate · multi-stmt · 1.06× JDBC (672 µs)Hibernate · update · 1.06× JDBC (592 µs)Hibernate · dynamic · 1.23× JDBC (930 µs)Hibernate · graph insert · 1.57× JDBC (3.92 ms)Hibernate · object graph · 1.30× JDBC (1.41 ms)Hibernate · batch insert · 1.77× JDBC (6.04 ms)Hibernate · keyset · 1.22× JDBC (490 µs)Hibernate · join·10 · 1.09× JDBC (700 µs)Hibernate · join·100 · 1.49× JDBC (1.10 ms)Hibernate · join·1k · 2.08× JDBC (5.10 ms)Hibernate jOOQ · PK lookup · 1.14× JDBC (212 µs)jOOQ · projection · 1.05× JDBC (1.01 ms)jOOQ · multi-stmt · 1.33× JDBC (843 µs)jOOQ · update · 1.36× JDBC (757 µs)jOOQ · dynamic · 1.26× JDBC (952 µs)jOOQ · graph insert · 1.18× JDBC (2.93 ms)jOOQ · object graph · 0.85× JDBC (913 µs)jOOQ · batch insert · 1.14× JDBC (3.87 ms)jOOQ · keyset · 1.27× JDBC (512 µs)jOOQ · join·10 · 1.11× JDBC (714 µs)jOOQ · join·100 · 1.40× JDBC (1.03 ms)jOOQ · join·1k · 1.90× JDBC (4.66 ms)jOOQ Exposed · PK lookup · 1.99× JDBC (371 µs)Exposed · projection · 1.25× JDBC (1.21 ms)Exposed · multi-stmt · 1.06× JDBC (676 µs)Exposed · update · 1.04× JDBC (582 µs)Exposed · dynamic · 1.50× JDBC (1.13 ms)Exposed · graph insert · 1.67× JDBC (4.17 ms)Exposed · object graph · 1.30× JDBC (1.40 ms)Exposed · batch insert · 1.90× JDBC (6.48 ms)Exposed · keyset · 1.63× JDBC (654 µs)Exposed · join·10 · 1.39× JDBC (893 µs)Exposed · join·100 · 1.46× JDBC (1.08 ms)Exposed · join·1k · 1.58× JDBC (3.87 ms)Exposed Exposed DAO · PK lookup · 2.08× JDBC (388 µs)Exposed DAO · projection · 1.25× JDBC (1.21 ms)Exposed DAO · multi-stmt · 1.13× JDBC (716 µs)Exposed DAO · update · 1.10× JDBC (611 µs)Exposed DAO · dynamic · 1.54× JDBC (1.17 ms)Exposed DAO · graph insert · 1.69× JDBC (4.21 ms)Exposed DAO · object graph · 1.82× JDBC (1.97 ms)Exposed DAO · batch insert · 2.04× JDBC (6.97 ms)Exposed DAO · keyset · 2.37× JDBC (952 µs)Exposed DAO · join·10 · 1.43× JDBC (918 µs)Exposed DAO · join·100 · 2.72× JDBC (2.00 ms)Exposed DAO · join·1k · 2.67× JDBC (6.56 ms)Exposed DAO Ktorm · PK lookup · 1.15× JDBC (215 µs)Ktorm · projection · 1.03× JDBC (1.00 ms)Ktorm · multi-stmt · 1.09× JDBC (690 µs)Ktorm · update · 1.09× JDBC (610 µs)Ktorm · dynamic · 1.25× JDBC (945 µs)Ktorm · graph insert · 1.09× JDBC (2.71 ms)Ktorm · object graph · 1.85× JDBC (2.00 ms)Ktorm · batch insert · 1.07× JDBC (3.66 ms)Ktorm · keyset · 2.53× JDBC (1.02 ms)Ktorm · join·10 · 1.26× JDBC (810 µs)Ktorm · join·100 · 1.68× JDBC (1.24 ms)Ktorm · join·1k · 2.83× JDBC (6.93 ms)Ktorm Jimmer · PK lookup · 1.08× JDBC (201 µs)Jimmer · projection · 1.02× JDBC (986 µs)Jimmer · multi-stmt · 1.10× JDBC (700 µs)Jimmer · update · 1.10× JDBC (612 µs)Jimmer · dynamic · 1.25× JDBC (951 µs)Jimmer · graph insert · 1.57× JDBC (3.92 ms)Jimmer · object graph · 1.59× JDBC (1.72 ms)Jimmer · batch insert · 1.78× JDBC (6.08 ms)Jimmer · keyset · 1.94× JDBC (779 µs)Jimmer · join·10 · 1.11× JDBC (714 µs)Jimmer · join·100 · 1.47× JDBC (1.09 ms)Jimmer · join·1k · 2.40× JDBC (5.89 ms)Jimmer transaction overhead, not ORM mapping
hover to highlight · click to toggle

The field falls into three groups. Storm is alone at the front on five workloads, with no framework within 3%: the primary-key lookup, keyset pagination and all three joins. On six more it is level with the leaders, inside a band narrower than the run-to-run noise: the projection, the dynamic query, both single-row writes and both batch writes. One workload goes to jOOQ, which takes the object graph with a single MULTISET JSON aggregate instead of repeated join rows, and Storm is second on it. Repeating the whole suite on identical hardware reproduces those three groups exactly, workload for workload.

The consistency is the part no other framework matches: Storm is in the top three on all twelve workloads and its worst placing anywhere in the suite is third. The next most consistent ORM reaches the top three on eight, and every other framework drops to fifth or lower somewhere, three of them to seventh. Only one ORM leads Storm anywhere in the suite, jOOQ on the object graph; the other five never lead on any of the twelve. A framework that is quick on the workloads it likes and mid-field on the rest is a different proposition from one with no weak workload at all.

Hydration is where the field spreads furthest: on the thousand-row join Storm carries at least 40% less per-row overhead than the closest framework, and the rest of the field pays at least 2.6x Storm's cost.

Storm's write advantage is a volume story: the batch and graph inserts run 1.4 to 1.7x faster than Hibernate, while all four writes sit in the leading group, none of them more than 1.5% off the front.

Per-workload charts: each framework's distance to the fastest, fork range on hover

Compare within a chart; each chart is a same-session comparison. The percentage is each framework's distance to the fastest; gold marks the leading group, every framework within 3%. Where more than one framework is in it they read level rather than ranked, because the gap between them is smaller than the run-to-run noise. Hover a row for its fork range.

Primary key lookup

Load one visit by primary key. The purest round-trip test: one query, one row.

JDBC 186 µs -3.4%
Storm 193 µs fastest
Jimmer 201 µs +4.3%
Hibernate 202 µs +4.8%
jOOQ 212 µs +9.8%
Ktorm 215 µs +12%
Exposed 371 µs +92%
Exposed DAO 388 µs +101%

† Transaction overhead.

Projection

Three columns across three tables into a flat DTO, one hundred rows.

JDBC 967 µs -0.4%
Hibernate 971 µs level
Storm 980 µs level
Jimmer 986 µs level
Ktorm 1001 µs +3.1%
jOOQ 1014 µs +4.4%
Exposed 1206 µs +24%
Exposed DAO 1207 µs +24%

Create then amend

Insert a visit, then amend it by its generated key, in one transaction.

JDBC 635 µs -5.3%
Storm 671 µs level
Hibernate 672 µs level
Exposed 676 µs level
Ktorm 690 µs level
Jimmer 700 µs +4.3%
Exposed DAO 716 µs +6.7%
jOOQ 843 µs +26%

Read, modify, update

Read one owner, change one field, persist atomically with one UPDATE.

JDBC 558 µs -3.8%
Storm 580 µs level
Exposed 582 µs level
Hibernate 592 µs level
Ktorm 610 µs +5.2%
Exposed DAO 611 µs +5.4%
Jimmer 612 µs +5.6%
jOOQ 757 µs +31%

Dynamic query

A filtered search assembled at runtime from a cycling set of optional predicates.

JDBC 759 µs -18%
Hibernate 930 µs level
Storm 942 µs level
Ktorm 945 µs level
Jimmer 951 µs level
jOOQ 952 µs level
Exposed 1134 µs +22%
Exposed DAO 1171 µs +26%

Graph insert

Write 20 owner-to-pet-to-visit graphs, generated keys threaded level to level.

JDBC 2.50 ms -8.0%
Ktorm 2.71 ms level
Storm 2.72 ms level
jOOQ 2.93 ms +8.1%
Hibernate 3.92 ms +45%
Jimmer 3.92 ms +45%
Exposed 4.17 ms +54%
Exposed DAO 4.21 ms +55%

Object graph

Load the owners of a city, each with their list of pets, grouped one-to-many.

JDBC 1079 µs +18%
jOOQ 913 µs fastest
Storm 1193 µs +31%
Exposed 1399 µs +53%
Hibernate 1408 µs +54%
Jimmer 1718 µs +88%
Exposed DAO 1969 µs +116%
Ktorm 2000 µs +119%

Batch insert

Insert 100 visits atomically and fetch their database-generated keys.

JDBC 3.41 ms -6.3%
Storm 3.64 ms level
Ktorm 3.66 ms level
jOOQ 3.87 ms +6.4%
Hibernate 6.04 ms +66%
Jimmer 6.08 ms +67%
Exposed 6.48 ms +78%
Exposed DAO 6.97 ms +91%

Keyset pagination

One page of 20 rows by keyset (seek) pagination, object graph materialized.

JDBC 402 µs -13%
Storm 461 µs fastest
Hibernate 490 µs +6.4%
jOOQ 512 µs +11%
Exposed 654 µs +42%
Jimmer 779 µs +69%
Exposed DAO 952 µs +107%
Ktorm 1018 µs +121%

Three-table join · 10 rows

Load pets with owner and city hydrated through a single three-table join.

JDBC 642 µs -4.7%
Storm 673 µs fastest
Hibernate 700 µs +4.0%
Jimmer 714 µs +6.0%
jOOQ 714 µs +6.1%
Ktorm 810 µs +20%
Exposed 893 µs +33%
Exposed DAO 918 µs +36%

Three-table join · 100 rows

The same join at 100 rows. Hydration cost starts to separate the field.

JDBC 737 µs -17%
Storm 891 µs fastest
jOOQ 1031 µs +16%
Exposed 1078 µs +21%
Jimmer 1087 µs +22%
Hibernate 1097 µs +23%
Ktorm 1239 µs +39%
Exposed DAO 2002 µs +125%

Three-table join · 1,000 rows

The same join at 1,000 rows. Row mapping now dominates the round trip.

JDBC 2.45 ms -25%
Storm 3.27 ms fastest
Exposed 3.87 ms +18%
jOOQ 4.66 ms +42%
Hibernate 5.10 ms +56%
Jimmer 5.89 ms +80%
Exposed DAO 6.56 ms +100%
Ktorm 6.93 ms +112%
Optimizations applied: each library's fastest documented setup

Each library gets the most performant solution its ecosystem documents; every entry is documented, recommended by production guidance for that library, and free of semantic penalty for its workload (the full rules are under Methodology). Storm's row is listed for symmetry: it runs unconfigured, and its fast paths are defaults rather than settings.

ScopeOptimizationEffect
EveryoneSequence-fed primary keys on the insert-target tablesNo library loses JDBC batching to an identity column; Hibernate's pooled generator (allocationSize = 50) allocates ids client-side.
Storm@DynamicUpdate(FIELD) on a purpose-built update shapeWrites only the changed column; the shape's lines count toward Storm's query LOC. Storm's only opt-in besides the PostgreSQL dialect module on the classpath; the multi-row RETURNING batches and the literal page size are default behavior.
Hibernate@DynamicUpdate on the owner entityWrites only the changed column.
HibernateHQL limit 20, a literalPostgreSQL caches the generic plan for the keyset join instead of replanning it on every call.
jOOQlimit(inline(20))The same plan-cache effect on the keyset query.
KtormbulkInsertReturning from ktorm-support-postgresqlOne multi-row INSERT … RETURNING per batch; core Ktorm would retrieve keys row by row.
JimmersetConstraintViolationTranslatable(false)Removes the SAVEPOINT / RELEASE pair around each save command; constraint violations surface as raw exceptions, which these workloads never read.
Semantic differences inside the scores

Result shapes are equivalent across libraries, but not every implementation does identical work. These are the differences worth knowing when reading a row.

WhereDifferenceIn the numbers
Create then amendStorm, Hibernate and Jimmer write the full row, since Visit declares no field-level change tracking; the change-tracking libraries write only the amended column.Same statement count, wider UPDATE.
Update, JimmerThe save writes every loaded column of the draft, not a change delta; the value change is still only the telephone.Wider UPDATE than the others' single column.
Keyset, Ktormtake(n) has no literal form, so the page size stays a bind parameter.PostgreSQL replans the three-table join on every call, a planning pass the literal-limit implementations skip.
Joins and keyset, Jimmer and Exposed DAOFetcher and eager-loading models load associations in follow-up batched queries.Extra round trips by design; query counts are listed with each workload.
Batch insert, HibernateIds are allocated client-side from the pooled sequence; no keys are requested from the driver.About two nextval calls per hundred rows; the rows go out as one JDBC batch of single-row statements.

Code comparison

Numbers without code invite tuned-benchmark suspicion, so the counts below, and the workloads that follow, show exactly what each library runs, trimmed of harness plumbing. The full sources for all eight implementations are in the benchmark repository.

Entities LOC

The entity or table definition file by the same counting rule. This is the model code developers write and maintain.

Storm 31 lines
Jimmer 57 lines
Exposed 58 lines
Ktorm 60 lines
Exposed DAO 74 lines
Hibernate 141 lines

JDBC and jOOQ have no hand-written model: JDBC maps rows by hand and jOOQ generates its table classes, so their cost appears in the suite total above instead.

Queries LOC

The workload file for all twelve workloads, including its row mapping, counted as non-blank, non-comment, non-import, non-package lines. Generated code is excluded for the two libraries that use it, Storm's metamodel and jOOQ's table classes, and result types shared across every implementation are excluded on all sides. Purpose-built shapes defined on top of a library's regular entity to speed a workload count toward its query lines.

Storm 161 lines
Ktorm 177 lines
Hibernatestring queries 188 lines
Exposed DAO 190 lines
Exposedhand-mapped rows 191 lines
jOOQhand-mapped rows 194 lines
Jimmer 270 lines
JDBChand-mapped rowsstring queries 395 lines

Storm implements all twelve workloads in the fewest lines; every other implementation needs 10% to 145% more. The counts come from scripts/count_loc.py in the benchmark repository, so they can be reverified against any checkout. Beyond the line count, the labels show what a low number can leave unsaid: hand-mapped rows are written and maintained by hand, and string queries are not compile-checked.

LOC is indicative, not conclusive: twelve workloads over a five-table schema is a small corpus, and a different application profile shifts the counts. It is presented as an illustration of these benchmark implementations, not as a universal measure of framework complexity.

Inspect each workload

Each workload shows Storm's implementation. Pick another library from the selector to compare it, or toggle Show SQL for the exact statement on the wire.

The model

Storm's model is plain data classes. Nullability, keys and relations live in the type: @FK val owner: Owner hydrates through a join, Ref<PetType> stays a lazy reference until asked. No proxies, no session lifecycle, nothing to configure. Compare it against the JPA entities, table objects and interfaces the other libraries declare for the same five tables.

Entities.kt Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
data class City(
    @PK val id: Long = 0,
    val name: String,
) : Entity<Long>

data class Owner(
    @PK val id: Long = 0,
    val firstName: String,
    val lastName: String,
    val address: String,
    val telephone: String,
    @FK val city: City,
) : Entity<Long>

data class Pet(
    @PK val id: Long = 0,
    val name: String,
    val birthDate: LocalDate,
    @FK val type: Ref<PetType>,
    @FK val owner: Owner,
) : Entity<Long>
1
2
3
4
5
6
7
8
// No ORM model. Rows are hand-mapped into plain records:
record City(long id, String name) {}

record Owner(long id, String firstName, String lastName,
               String address, String telephone, City city) {}

record Pet(long id, String name, LocalDate birthDate,
             long typeId, Owner owner) {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Private fields, field access. Getters/setters added only where the app calls them.
@Entity @Table(name = "city")
class City {
    @Id @GeneratedValue(strategy = IDENTITY) Long id;
    @Column(name = "name") String name;
}

@Entity @Table(name = "owner") @DynamicUpdate
class Owner {
    @Id @GeneratedValue(strategy = IDENTITY) Long id;
    @Column(name = "first_name") String firstName;
    @Column(name = "last_name") String lastName;
    @Column(name = "address") String address;
    @Column(name = "telephone") String telephone;
    @ManyToOne(fetch = LAZY) @JoinColumn(name = "city_id") City city;
    @OneToMany(mappedBy = "owner") List<Pet> pets;
}

@Entity @Table(name = "pet")
class Pet {
    @Id @GeneratedValue(strategy = IDENTITY) Long id;
    @Column(name = "name") String name;
    @Column(name = "birth_date") LocalDate birthDate;
    @ManyToOne(fetch = LAZY) @JoinColumn(name = "type_id") PetType type;
    @ManyToOne(fetch = LAZY) @JoinColumn(name = "owner_id") Owner owner;
}
1
2
3
4
5
6
7
8
9
// jOOQ generates the table classes from the schema; there is no entity model.
// You still write the result records it maps into, shared here with JDBC:
record City(long id, String name) {}

record Owner(long id, String firstName, String lastName,
               String address, String telephone, City city) {}

record Pet(long id, String name, LocalDate birthDate,
             long typeId, Owner owner) {}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
object Cities : Table("city") {
    val id = long("id").autoIncrement()
    val name = varchar("name", 100)
    override val primaryKey = PrimaryKey(id)
}

object Owners : Table("owner") {
    val id = long("id").autoIncrement()
    val firstName = varchar("first_name", 50)
    val lastName = varchar("last_name", 50)
    val address = varchar("address", 120)
    val telephone = varchar("telephone", 20)
    val cityId = long("city_id").references(Cities.id)
    override val primaryKey = PrimaryKey(id)
}

object Pets : Table("pet") {
    val id = long("id").autoIncrement()
    val name = varchar("name", 50)
    val birthDate = date("birth_date")
    val typeId = long("type_id").references(PetTypes.id)
    val ownerId = long("owner_id").references(Owners.id)
    override val primaryKey = PrimaryKey(id)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
object Cities : LongIdTable("city") { val name = varchar("name", 100) }

object Owners : LongIdTable("owner") {
    val firstName = varchar("first_name", 50)
    val lastName = varchar("last_name", 50)
    val address = varchar("address", 120)
    val telephone = varchar("telephone", 20)
    val cityId = reference("city_id", Cities)
}

object Pets : LongIdTable("pet") {
    val name = varchar("name", 50)
    val birthDate = date("birth_date")
    val typeId = reference("type_id", PetTypes)
    val ownerId = reference("owner_id", Owners)
}

class CityDao(id: EntityID<Long>) : LongEntity(id) {
    companion object : LongEntityClass<CityDao>(Cities)
    var name by Cities.name
}

class OwnerDao(id: EntityID<Long>) : LongEntity(id) {
    companion object : LongEntityClass<OwnerDao>(Owners)
    var firstName by Owners.firstName
    var lastName by Owners.lastName
    var address by Owners.address
    var telephone by Owners.telephone
    var city by CityDao referencedOn Owners.cityId
    val pets by PetDao referrersOn Pets.ownerId
}

class PetDao(id: EntityID<Long>) : LongEntity(id) {
    companion object : LongEntityClass<PetDao>(Pets)
    var name by Pets.name
    var birthDate by Pets.birthDate
    var typeId by Pets.typeId
    var owner by OwnerDao referencedOn Pets.ownerId
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Owner : Entity<Owner> {
    companion object : Entity.Factory<Owner>()
    val id: Long
    var firstName: String
    var lastName: String
    var address: String
    var telephone: String
    var city: City
}

object Owners : Table<Owner>("owner") {
    val id = long("id").primaryKey().bindTo { it.id }
    val firstName = varchar("first_name").bindTo { it.firstName }
    // … lastName, address and telephone bindings …
    val cityId = long("city_id").references(Cities) { it.city }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Entity @Table(name = "city")
interface City {
    @Id long id();
    String name();
}

@Entity @Table(name = "owner")
interface Owner {
    @Id long id();
    String firstName();
    String lastName();
    String address();
    String telephone();
    @ManyToOne @JoinColumn(name = "city_id") City city();
    @OneToMany(mappedBy = "owner") List<Pet> pets();
}

@Entity @Table(name = "pet")
interface Pet {
    @Id long id();
    String name();
    LocalDate birthDate();
    @ManyToOne @JoinColumn(name = "type_id") PetType type();
    @ManyToOne @JoinColumn(name = "owner_id") Owner owner();
}
Primary key lookup

Load one visit by its primary key: one query, one row, the purest round-trip test. The pet reference stays lazy for every implementation (a Ref in Storm, a proxy or plain id elsewhere), so no join runs and the wire round trip dominates the score. What separates libraries here is per-call machinery: building the statement, binding one value and mapping one row.

singleRowById Kotlin
1
val visit = visits.getById(id)
1
2
3
4
5
6
7
8
9
try (var ps = connection.prepareStatement(
        "SELECT id, pet_id, visit_date, description FROM visit WHERE id = ?")) {
    ps.setLong(1, id);
    try (var rs = ps.executeQuery()) {
        rs.next();
        return new Visit(rs.getLong(1), rs.getLong(2),
                rs.getObject(3, LocalDate.class), rs.getString(4));
    }
}
1
return sessionFactory.fromSession(session -> session.find(Visit.class, id));
1
2
3
4
return ctx.select(VISIT.ID, VISIT.PET_ID, VISIT.VISIT_DATE, VISIT.DESCRIPTION)
        .from(VISIT)
        .where(VISIT.ID.eq(id))
        .fetchOne(Records.mapping(Visit::new));
1
2
3
transaction(database) {
    Visits.selectAll().where { Visits.id eq id }.single().toVisit()
}
1
2
3
transaction(database) {
    VisitDao.findById(id)!!.toVisit()
}
1
database.sequenceOf(Visits).find { it.id eq id }!!
1
return sqlClient.getEntities().findById(Visit.class, id);
generated sql
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
LIMIT ?  -- find appends a bound limit
SELECT v.id, v.pet_id, v.visit_date, v.description
FROM visit v
WHERE v.id = ?
Projection

A template picks three columns across the graph; the metamodel keeps every path compile-checked.

projection Kotlin
1
2
3
4
5
data class PetRow(val petName: String, val ownerLastName: String, val cityName: String)

val rows = orm.selectFrom<Pet, PetRow> { "${Pet_.name}, ${Pet_.owner.lastName}, ${Pet_.owner.city.name}" }
    .where(Pet_.owner.city.id eq cityId)
    .resultList
1
2
3
4
5
6
7
8
9
10
11
try (var ps = connection.prepareStatement(
        "SELECT p.name, o.last_name, c.name FROM pet p"
        + " JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id"
        + " WHERE o.city_id = ?")) {
    ps.setLong(1, cityId);
    try (var rs = ps.executeQuery()) {
        List<PetRow> rows = new ArrayList<>();
        while (rs.next()) rows.add(new PetRow(rs.getString(1), rs.getString(2), rs.getString(3)));
        return rows;
    }
}
1
2
3
4
5
6
return sessionFactory.fromSession(session -> session
    .createSelectionQuery(
        "select p.name, o.lastName, c.name from Pet p join p.owner o join o.city c where c.id = :cityId",
        PetRow.class)
    .setParameter("cityId", cityId)
    .getResultList());
1
2
3
4
5
6
return ctx.select(PET.NAME, OWNER.LAST_NAME, CITY.NAME)
    .from(PET)
    .join(OWNER).on(PET.OWNER_ID.eq(OWNER.ID))
    .join(CITY).on(OWNER.CITY_ID.eq(CITY.ID))
    .where(OWNER.CITY_ID.eq(cityId))
    .fetch(Records.mapping(PetRow::new));
1
2
3
4
5
6
transaction(database) {
    (Pets innerJoin Owners innerJoin Cities)
        .select(Pets.name, Owners.lastName, Cities.name)
        .where { Owners.cityId eq cityId }
        .map { PetRow(it[Pets.name], it[Owners.lastName], it[Cities.name]) }
}
1
2
3
4
5
6
7
// Exposed DAO drops to the same DSL query for projections
transaction(database) {
    (Pets innerJoin Owners innerJoin Cities)
        .select(Pets.name, Owners.lastName, Cities.name)
        .where { Owners.cityId eq EntityID(cityId, Cities) }
        .map { PetRow(it[Pets.name], it[Owners.lastName], it[Cities.name]) }
}
1
2
3
4
5
6
database.from(Pets)
    .innerJoin(Owners, on = Pets.ownerId eq Owners.id)
    .innerJoin(Cities, on = Owners.cityId eq Cities.id)
    .select(Pets.name, Owners.lastName, Cities.name)
    .where { Owners.cityId eq cityId }
    .map { PetRow(it[Pets.name]!!, it[Owners.lastName]!!, it[Cities.name]!!) }
1
2
3
4
5
PetTable table = PetTable.$;
return sqlClient.createQuery(table)
    .where(table.owner().city().id().eq(cityId))
    .select(table.name(), table.owner().lastName(), table.owner().city().name())
    .execute();
generated sql
SELECT p.name, o.last_name, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE o.city_id = ?
Create then amend

One transaction, two dependent statements: insert a visit, then amend it using the generated key. Storm returns the key from the insert and updates a copy of the immutable record; the entity libraries amend a managed instance and let change tracking write it.

multiStatement Kotlin
1
2
3
4
5
6
return transaction {
    val visit = Visit(pet = refById<Pet>(petId), visitDate = date, description = text)
    val id = visits.insertAndFetchId(visit)
    visits.update(visit.copy(id = id, description = "${visit.description} (rechecked)"))
    id
}
1
2
3
4
5
6
7
8
try (var insert = connection.prepareStatement(
        "INSERT INTO visit (pet_id, visit_date, description) VALUES (?, ?, ?)",
        Statement.RETURN_GENERATED_KEYS)) {
    // bind, executeUpdate, read the key from getGeneratedKeys()
}
try (var update = connection.prepareStatement("UPDATE visit SET description = ? WHERE id = ?")) {
    // bind the amended description and the key, executeUpdate, commit
}
1
2
3
4
5
6
7
8
return sessionFactory.fromTransaction(session -> {
    Visit visit = new Visit(session.getReference(Pet.class, petId), date, text);
    session.persist(visit);
    session.flush();
    // the persisted instance is managed; amend it and let dirty checking flush
    visit.setDescription(visit.getDescription() + " (rechecked)");
    return visit.getId();
});
1
2
3
4
5
6
7
Long id = c.insertInto(VISIT, VISIT.PET_ID, VISIT.VISIT_DATE, VISIT.DESCRIPTION)
        .values(petId, date, text)
        .returning(VISIT.ID).fetchOne().getId();
c.update(VISIT)
        .set(VISIT.DESCRIPTION, text + " (rechecked)")
        .where(VISIT.ID.eq(id))
        .execute();
1
2
3
4
5
val inserted = Visits.insert {
    it[Visits.petId] = petId; it[Visits.visitDate] = date; it[Visits.description] = text
}
val id = inserted[Visits.id]  // the insert result carries the generated id
Visits.update({ Visits.id eq id }) { it[Visits.description] = "$text (rechecked)" }
1
2
3
val dao = VisitDao.new { petId = …; visitDate = date; description = text }
val id = dao.id.value  // reading the id forces the pending insert to flush
dao.description = dao.description + " (rechecked)"
1
2
3
4
val visit = Visit { petId = pid; visitDate = date; description = text }
database.sequenceOf(Visits).add(visit)  // populates the generated id
visit.description = "${visit.description} (rechecked)"
visit.flushChanges()
1
2
3
4
5
Visit saved = sqlClient.getEntities().saveCommand(visit)
        .setMode(SaveMode.INSERT_ONLY).execute(connection).getModifiedEntity();
Visit updated = VisitDraft.$.produce(saved,
        draft -> draft.setDescription(saved.description() + " (rechecked)"));
sqlClient.getEntities().saveCommand(updated).setMode(SaveMode.UPDATE_ONLY).execute(connection);
generated sql
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)
RETURNING id

UPDATE visit
SET pet_id = ?, visit_date = ?, description = ?  -- the full row: Visit declares no field-level update tracking
WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- the generated key comes back through RETURNING

UPDATE visit
SET description = ?  -- only the amended column is written
WHERE id = ?
SELECT nextval('visit_seq')  -- at most once per 50 inserts: the pooled optimizer allocates client-side

INSERT INTO visit (description, pet_id, visit_date, id)
VALUES (?, ?, ?, ?)  -- id assigned client-side

UPDATE visit
SET description = ?, pet_id = ?, visit_date = ?  -- dirty checking writes the full row: Visit has no @DynamicUpdate
WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- the generated key comes back through RETURNING

UPDATE visit
SET description = ?  -- only the amended column is written
WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- the generated key comes back through RETURNING

UPDATE visit
SET description = ?  -- only the amended column is written
WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- the generated key comes back through RETURNING

UPDATE visit
SET description = ?  -- only the amended column is written
WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- the generated key comes back through RETURNING

UPDATE visit
SET description = ?  -- only the amended column is written
WHERE id = ?
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)
RETURNING id

UPDATE visit
SET pet_id = ?, visit_date = ?, description = ?  -- the full row: Visit declares no field-level update tracking
WHERE id = ?
Read, modify, update

Storm's regular Owner is an aggregate: reading one loads its city through a join. Every other library declares that association lazy and reads the owner row alone, so to keep the read side of this workload identical for everyone, the benchmark uses a dedicated shape of the same table where city stays a lazy Ref. That shape is one record; declaring it is Storm's equivalent of the FetchType.LAZY the others put on their entities, and its ten lines are counted against Storm in the Queries LOC table above. On the write side, @DynamicUpdate(FIELD) writes only the column that changed. Entities are immutable; an update is a copy.

updateById Kotlin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@DbTable("owner")  // in-place optimization for writing: city as lazy ref
@DynamicUpdate(UpdateMode.FIELD)
data class OwnerCityRef(
    @PK val id: Long = 0,
    val firstName: String,
    val lastName: String,
    val address: String,
    val telephone: String,
    @FK val city: Ref<City>,
) : Entity<Long>

transaction {
    val owner = owners.getById(id)
    owners.update(owner.copy(telephone = newTelephone))  // only the changed column is written
}
1
2
3
4
5
6
7
8
9
10
11
12
13
connection.setAutoCommit(false);
String phone;
try (var ps = connection.prepareStatement(
        "SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = ?")) {
    ps.setLong(1, id);
    var rs = ps.executeQuery(); rs.next();
    phone = Params.toggleTelephone(rs.getString(5));
    owner = new Owner(rs.getLong(1), ..., phone, null);
}
try (var ps = connection.prepareStatement("UPDATE owner SET telephone = ? WHERE id = ?")) {
    ps.setString(1, phone); ps.setLong(2, id); ps.executeUpdate();
}
connection.commit();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Entity @Table(name = "owner") @DynamicUpdate  // write only the changed columns
class Owner {
    @Id @GeneratedValue(strategy = IDENTITY) private Long id;
    @Column(name = "first_name") private String firstName;
    @Column(name = "last_name") private String lastName;
    @Column(name = "address") private String address;
    @Column(name = "telephone") private String telephone;
    @ManyToOne(fetch = LAZY) @JoinColumn(name = "city_id") private City city;  // lazy: city not read
    @OneToMany(mappedBy = "owner") private List<Pet> pets;

    public String getTelephone() { return telephone; }
    public void setTelephone(String telephone) { this.telephone = telephone; }
    // Hibernate maps via field access; getId()/getPets() added where the app needs them
}

sessionFactory.fromTransaction(session -> {
    Owner owner = session.find(Owner.class, id);
    owner.setTelephone(Params.toggleTelephone(owner.getTelephone()));
    return owner;  // dirty-checking flushes only telephone
});
1
2
3
4
5
6
return ctx.transactionResult(tx -> {
    var record = DSL.using(tx).fetchOne(OWNER, OWNER.ID.eq(id));
    record.setTelephone(Params.toggleTelephone(record.getTelephone()));
    record.store();  // UpdatableRecord.store() updates only the changed field
    return record.getId();
});
1
2
3
4
5
transaction(database) {
    val row = Owners.selectAll().where { Owners.id eq id }.single()
    val phone = Params.toggleTelephone(row[Owners.telephone])
    Owners.update({ Owners.id eq id }) { it[Owners.telephone] = phone }
}
1
2
3
4
transaction(database) {
    val dao = OwnerDao.findById(id) ?: error("owner not found")
    dao.telephone = Params.toggleTelephone(dao.telephone)  // dirty tracking flushes only telephone
}
1
2
3
4
5
6
database.useTransaction {
    // withReferences = false keeps the read lazy: owner columns only, no city join
    val owner = database.sequenceOf(Owners, withReferences = false).find { it.id eq id }!!
    owner.telephone = Params.toggleTelephone(owner.telephone)
    owner.flushChanges()  // dirty tracking writes only the changed column
}
1
2
3
4
5
6
Owner owner = sqlClient.createQuery(OwnerTable.$)
    .where(OwnerTable.$.id().eq(id)).select(OwnerTable.$).execute(connection).getFirst();
Owner updated = OwnerDraft.$.produce(owner, d -> d.setTelephone(Params.toggleTelephone(owner.telephone())));
sqlClient.getEntities().saveCommand(updated)
    .setMode(SaveMode.UPDATE_ONLY)  // writes every loaded column of the draft
    .execute(connection);
generated sql
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o
WHERE o.id = ?

UPDATE owner
SET telephone = ?
WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o
WHERE o.id = ?

UPDATE owner
SET telephone = ?
WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o
WHERE o.id = ?

UPDATE owner
SET telephone = ?
WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o
WHERE o.id = ?

UPDATE owner
SET telephone = ?
WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o
WHERE o.id = ?

UPDATE owner
SET telephone = ?
WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o
WHERE o.id = ?

UPDATE owner
SET telephone = ?
WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o
WHERE o.id = ?

UPDATE owner
SET telephone = ?
WHERE id = ?
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o WHERE o.id = ?

UPDATE owner
SET first_name = ?, last_name = ?, address = ?, telephone = ?, city_id = ?
WHERE id = ?  -- the save writes every loaded column of the draft; only the telephone value changed
Dynamic query

A filtered search assembled at runtime from optional predicates. Storm composes type-safe predicates with and and keeps the projection a flat row type. JDBC and Hibernate grow the query string; jOOQ, Exposed, Ktorm and Jimmer compose typed conditions of their own. The SQL carries only the predicates that are active.

dynamic Kotlin
1
2
3
4
5
6
var predicate: PredicateBuilder<Pet, *, *> = Pet_.owner.city.id eq filter.cityId
if (filter.byDate) predicate = predicate and (Pet_.birthDate greaterEq filter.minBirthDate)
if (filter.byType) predicate = predicate and (Pet_.type eq refById<PetType>(filter.typeId))
return pets.select<PetRow, _, _> { "${Pet_.name}, ${Pet_.owner.lastName}, ${Pet_.owner.city.name}" }
    .where(predicate)
    .resultList
1
2
3
4
var sql = new StringBuilder("SELECT p.name, o.last_name, c.name FROM pet p JOIN … WHERE o.city_id = ?");
if (filter.byDate()) sql.append(" AND p.birth_date >= ?");
if (filter.byType()) sql.append(" AND p.type_id = ?");
// bind the parameters in the same order the string grew, then map each row
1
2
3
4
var hql = new StringBuilder("select p.name, o.lastName, c.name from Pet p join p.owner o join o.city c where o.city.id = :cityId");
if (filter.byDate()) hql.append(" and p.birthDate >= :minDate");
if (filter.byType()) hql.append(" and p.type.id = :typeId");
// create the query, set the parameters that are present, getResultList()
1
2
3
4
5
6
7
Condition condition = OWNER.CITY_ID.eq(filter.cityId());
if (filter.byDate()) condition = condition.and(PET.BIRTH_DATE.ge(filter.minBirthDate()));
if (filter.byType()) condition = condition.and(PET.TYPE_ID.eq(filter.typeId()));
return ctx.select(PET.NAME, OWNER.LAST_NAME, CITY.NAME)
        .from(PET).join(OWNER).on(…).join(CITY).on(…)
        .where(condition)
        .fetch(Records.mapping(PetRow::new));
1
2
3
4
5
6
7
8
9
(Pets innerJoin Owners innerJoin Cities)
    .select(Pets.name, Owners.lastName, Cities.name)
    .where {
        var condition: Op<Boolean> = Owners.cityId eq filter.cityId
        if (filter.byDate) condition = condition and (Pets.birthDate greaterEq filter.minBirthDate)
        if (filter.byType) condition = condition and (Pets.typeId eq filter.typeId)
        condition
    }
    .map { PetRow(it[Pets.name], it[Owners.lastName], it[Cities.name]) }
1
2
// The DAO layer drops to the DSL for flat projections, as DAO applications do;
// the implementation matches Exposed with EntityID-wrapped key comparisons.
1
2
3
4
5
6
7
8
val conditions = ArrayList<ColumnDeclaring<Boolean>>()
conditions += Owners.cityId eq filter.cityId
if (filter.byDate) conditions += Pets.birthDate greaterEq filter.minBirthDate
if (filter.byType) conditions += Pets.typeId eq filter.typeId
database.from(Pets).innerJoin(Owners, on = …).innerJoin(Cities, on = …)
    .select(Pets.name, Owners.lastName, Cities.name)
    .where { conditions.reduce { a, b -> a and b } }
    .map { PetRow(it[Pets.name]!!, it[Owners.lastName]!!, it[Cities.name]!!) }
1
2
3
4
5
6
7
// Jimmer ignores null predicates, its idiom for dynamic queries.
return sqlClient.createQuery(table)
        .where(table.owner().city().id().eq(filter.cityId()))
        .where(filter.byDate() ? table.birthDate().ge(filter.minBirthDate()) : null)
        .where(filter.byType() ? table.type().id().eq(filter.typeId()) : null)
        .select(table.name(), table.owner().lastName(), table.owner().city().name())
        .execute();
generated sql
SELECT p.name, o.last_name, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE o.city_id = ? AND p.birth_date >= ? AND p.type_id = ?
-- the optional predicates appear only when the filter sets them
Graph insert

Twenty owner-to-pet-to-visit graphs written in one transaction, generated keys propagated from parent to child. Storm receives only the visits: the write set discovers the unsaved pets and owners through the refs, orders the dependency levels and writes one multi-row statement per type. The workload returns the generated visit ids, the contract of a create endpoint. Every other implementation except Hibernate orders the levels itself.

graphInsert Kotlin
1
2
3
4
5
6
7
8
9
val visits = graphs.map { g ->
    val owner = Owner(firstName = …, lastName = …, address = …, telephone = …,
                      city = City(id = g.cityId, name = ""))
    val pet = Pet(name = …, birthDate = …, type = refById<PetType>(g.typeId), owner = owner)
    Visit(pet = Ref.of(pet), visitDate = …, description = …)
}
// Only the visits are passed: the write set discovers the unsaved pets and owners through the
// refs, writes one multi-row statement per type per dependency level and propagates the keys.
return transaction { orm.writeSet().insertAndFetchIds(visits) }
1
2
3
4
List<Long> ownerIds = multiRowInsertReturningKeys(connection, "owner", …);
List<Long> petIds   = multiRowInsertReturningKeys(connection, "pet", …);   // threads ownerIds
List<Long> visitIds = multiRowInsertReturningKeys(connection, "visit", …); // threads petIds
// each level is one INSERT … VALUES (…),(…) RETURNING id; the caller orders the levels
1
2
3
4
5
6
7
8
for (var g : graphs) {
    Owner owner = new Owner(…, session.getReference(City.class, g.cityId()));
    Pet pet = new Pet(…, owner); owner.getPets().add(pet);
    Visit visit = new Visit(pet, …); pet.getVisits().add(visit);
    // cascade persist walks owner -> pets -> visits, ordering and batching the inserts
    session.persist(owner);
}
session.flush();
1
2
3
4
5
var ownerInsert = c.insertInto(OWNER, OWNER.FIRST_NAME, …);
for (var g : graphs) ownerInsert = ownerInsert.values(…);
List<Long> ownerIds = ownerInsert.returning(OWNER.ID).fetch().map(r -> r.get(OWNER.ID));
// same shape for pets (threading ownerIds) and visits (threading petIds):
// three multi-row INSERT … RETURNING statements, ordered by the caller
1
2
3
4
val ownerIds = Owners.batchInsert(graphs, shouldReturnGeneratedValues = true) { g ->
    this[Owners.firstName] = …; this[Owners.cityId] = g.cityId
}.map { it[Owners.id] }
// same shape for pets (threading ownerIds) and visits (threading petIds)
1
2
// Identical to Exposed: three batchInsert calls with generated values returned,
// with EntityID-wrapped keys threaded between the levels.
1
2
3
4
5
// bulkInsertReturning (ktorm-support-postgresql): one multi-row INSERT … RETURNING per level.
val ownerIds = database.bulkInsertReturning(Owners, Owners.id) {
    for (g in graphs) { item { set(it.firstName, …); set(it.cityId, g.cityId) } }
}
// same shape for pets (threading ownerIds) and visits (threading petIds)
1
2
3
4
List<Long> ownerIds = saveEntitiesReturningIds(connection, ownerDrafts, Owner::id);
List<Long> petIds   = saveEntitiesReturningIds(connection, petDrafts, Pet::id);  // threads ownerIds
List<Long> visitIds = saveEntitiesReturningIds(connection, visitDrafts, Visit::id);
// three saveEntities commands, each level threading the previous ids
generated sql
INSERT INTO owner (first_name, last_name, address, telephone, city_id)
VALUES (?, ?, ?, ?, ?), …  -- 20 value rows
RETURNING "id"

INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id"
INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id"
-- no re-read: the workload returns the generated visit ids
INSERT INTO owner (first_name, last_name, address, telephone, city_id)
VALUES (?, ?, ?, ?, ?), …  -- 20 value rows
RETURNING "id"

INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id"
INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id"
-- no re-read: the workload returns the generated visit ids
SELECT nextval('owner_seq')  -- pooled sequences allocate the ids client-side

INSERT INTO owner (…, id) VALUES (?, …)  -- batched x20 per type, ordered by
-- ORDER_INSERTS: owners, then pets, then visits
INSERT INTO owner (first_name, last_name, address, telephone, city_id)
VALUES (?, ?, ?, ?, ?), …  -- 20 value rows
RETURNING "id"

INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id"
INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id"
-- no re-read: the workload returns the generated visit ids
INSERT INTO owner (…) VALUES (?, …) RETURNING *  -- one row per statement,
INSERT INTO pet (…) VALUES (?, …) RETURNING *  -- one JDBC batch of 20 per level
INSERT INTO visit (…) VALUES (?, …) RETURNING *
INSERT INTO owner (…) VALUES (?, …) RETURNING *  -- one row per statement,
INSERT INTO pet (…) VALUES (?, …) RETURNING *  -- one JDBC batch of 20 per level
INSERT INTO visit (…) VALUES (?, …) RETURNING *
INSERT INTO owner (first_name, last_name, address, telephone, city_id)
VALUES (?, ?, ?, ?, ?), …  -- 20 value rows
RETURNING "id"

INSERT INTO pet (name, birth_date, type_id, owner_id) VALUES …, RETURNING "id"
INSERT INTO visit (pet_id, visit_date, description) VALUES …, RETURNING "id"
-- no re-read: the workload returns the generated visit ids
INSERT INTO owner (…) VALUES (?, …) RETURNING id  -- one row per statement,
INSERT INTO pet (…) VALUES (?, …) RETURNING id  -- one JDBC batch of 20 per level
INSERT INTO visit (…) VALUES (?, …) RETURNING id
Object graph

Load the owners of a city, each with their list of pets. Storm, JDBC, Exposed and Ktorm run one three-table join and group the rows during hydration; in Storm, repeated owners deduplicate to the same instance, so grouping is an identity operation rather than a hash of every field. jOOQ nests the pets server-side with MULTISET, Hibernate collapses its join fetch cartesian with distinct, and Exposed DAO and Jimmer load the pets in a follow-up batched query.

objectGraph Kotlin
1
2
3
4
5
val result = pets.select()
    .where(Pet_.owner.city.id eq cityId)
    .orderBy(Pet_.owner)
    .resultGroupedBy(Pet_.owner)
    .map { (owner, pets) -> OwnerWithPets(owner, pets) }
1
2
3
4
5
6
7
8
9
10
11
12
Map<Long, Owner> owners = new LinkedHashMap<>();
Map<Long, List<Pet>> pets = new LinkedHashMap<>();
while (rs.next()) {
    long ownerId = rs.getLong(1);
    Owner owner = owners.get(ownerId);
    if (owner == null) {
        owner = new Owner(ownerId, ..., new City(rs.getLong(6), rs.getString(7)));
        owners.put(ownerId, owner); pets.put(ownerId, new ArrayList<>());
    }
    pets.get(ownerId).add(new Pet(rs.getLong(8), ..., owner));
}
// then zip owners + pets into List<OwnerWithPets>
1
2
3
4
5
6
List<Owner> owners = sessionFactory.fromSession(session -> session
    .createSelectionQuery(
        "select distinct o from Owner o join fetch o.pets join fetch o.city where o.city.id = :cityId",
        Owner.class)
    .setParameter("cityId", cityId)
    .getResultList());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
List<OwnerWithPets> result = ctx.select(
            OWNER.ID, OWNER.FIRST_NAME, OWNER.LAST_NAME, OWNER.ADDRESS, OWNER.TELEPHONE,
            CITY.ID, CITY.NAME,
            multiset(
                    select(PET.ID, PET.NAME, PET.BIRTH_DATE, PET.TYPE_ID)
                            .from(PET)
                            .where(PET.OWNER_ID.eq(OWNER.ID))))
    .from(OWNER)
    .join(CITY).on(OWNER.CITY_ID.eq(CITY.ID))
    .where(OWNER.CITY_ID.eq(cityId))
    .orderBy(OWNER.ID)
    .fetch(record -> {
        Owner owner = new Owner(record.value1(), record.value2(), record.value3(),
                record.value4(), record.value5(), new City(record.value6(), record.value7()));
        List<Pet> pets = record.value8()
                .map(pet -> new Pet(pet.value1(), pet.value2(), pet.value3(), pet.value4(), owner));
        return new OwnerWithPets(owner, pets);
    });
1
2
3
4
5
6
7
transaction(database) {
    (Owners innerJoin Cities innerJoin Pets)
        .selectAll()
        .where { Owners.cityId eq cityId }
        .orderBy(Owners.id)
        .groupIntoOwners()  // in-memory LinkedHashMap grouping
}
1
2
3
4
5
6
transaction(database) {
    OwnerDao.find { Owners.cityId eq EntityID(cityId, Cities) }
        .orderBy(Owners.id to SortOrder.ASC)
        .with(OwnerDao::city, OwnerDao::pets)  // pets = batched SELECT ... WHERE owner_id IN (...)
        .map { OwnerWithPets(it.toOwner(), it.pets.map { p -> p.toPet() }) }
}
1
2
3
4
5
6
7
8
database.from(Owners)
    .innerJoin(Cities, on = Owners.cityId eq Cities.id)
    .innerJoin(Pets, on = Pets.ownerId eq Owners.id)
    .select()
    .where { Owners.cityId eq cityId }
    .orderBy(Owners.id.asc())
    .map { Pets.createEntity(it) }
    .groupIntoOwners()  // in-memory LinkedHashMap grouping
1
2
3
4
5
6
7
8
9
OwnerTable table = OwnerTable.$;
return sqlClient.createQuery(table)
    .where(table.city().id().eq(cityId))
    .orderBy(table.id().asc())
    .select(table.fetch(
        OwnerFetcher.$.allScalarFields()
            .city(CityFetcher.$.allScalarFields())
            .pets(PetFetcher.$.allScalarFields())))  // pets = batched WHERE owner_id IN (...)
    .execute();
generated sql
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE o.city_id = ?
ORDER BY p.owner_id
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE o.city_id = ?
ORDER BY p.owner_id
SELECT DISTINCT
       o.id, o.first_name, o.last_name, o.address, o.telephone,
       p.owner_id, p.id, p.birth_date, p.name, p.type_id, c.id, c.name
FROM owner o
JOIN pet  p ON o.id = p.owner_id
JOIN city c ON c.id = o.city_id
WHERE c.id = ?  -- DISTINCT collapses the owner x pet cartesian, no ORDER BY
SELECT owner.id, owner.first_name, owner.last_name, owner.address, owner.telephone,
       city.id, city.name,
       (SELECT jsonb_agg(jsonb_build_array(p.id, p.name, p.birth_date, p.type_id))
        FROM pet p WHERE p.owner_id = owner.id) AS pets  -- correlated MULTISET, no pet join
FROM owner
JOIN city ON owner.city_id = city.id
WHERE owner.city_id = ?
ORDER BY owner.id
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE o.city_id = ?
ORDER BY p.owner_id
-- 1) main owners query
SELECT owner.id, ..., owner.city_id FROM owner WHERE owner.city_id = ? ORDER BY owner.id

-- 2) the city (one per workload call)
SELECT city.id, city.name FROM city WHERE city.id = ?

-- 3) batched pets (the collection)
SELECT pet.id, pet.name, pet.birth_date, pet.type_id, pet.owner_id
FROM pet WHERE pet.owner_id IN (?, ?, ...)
SELECT *  -- the DSL join selects every column of the three tables
FROM owner
INNER JOIN city ON owner.city_id = city.id
INNER JOIN pet ON pet.owner_id = owner.id
WHERE owner.city_id = ?
ORDER BY owner.id
-- 1) main owners query (city().id() folds to owner.city_id)
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o WHERE o.city_id = ? ORDER BY o.id

-- 2) the city (one per workload call)
SELECT c.id, c.name FROM city c WHERE c.id = ?

-- 3) batched pets collection (the OneToMany), chunked into several ANY batches
SELECT p.owner_id, p.id, p.name, p.birth_date, p.type_id
FROM pet p WHERE p.owner_id = ANY(?)
Batch insert

Insert one hundred visits in a transaction and return their generated keys. Storm emits a single multi-row INSERT carrying all hundred rows and reads the keys from its RETURNING clause; jOOQ builds the same statement from a hundred values() tuples, and Ktorm matches it through bulkInsertReturning from its PostgreSQL support module. Exposed, Exposed DAO and Jimmer read the keys back from a JDBC batch of single-row statements, and Hibernate assigns its ids client-side from the pooled sequence and batches the same way, asking for no keys back.

batchInsert Kotlin
1
2
3
val ids = transaction {
    visits.insertAndFetchIds(newVisits)  // 100 visits, one multi-row INSERT returning the keys
}
1
2
3
4
5
6
List<Long> ids = multiRowInsertReturningKeys(connection, "visit",
        "pet_id, visit_date, description", 3, BATCH_SIZE, (ps, base, i) -> {
    ps.setLong(base + 1, ...); ps.setObject(base + 2, ...); ps.setString(base + 3, ...);
});
// one INSERT ... VALUES (...),(...) RETURNING id: the driver disables batch rewriting
// when generated keys are requested, so executeBatch cannot express this technique
1
2
3
4
5
6
7
8
sessionFactory.fromTransaction(session -> {
    for (int i = 0; i < BATCH_SIZE; i++) {
        Pet pet = session.getReference(Pet.class, ...);  // proxy, no SELECT
        session.persist(new Visit(pet, ..., ...));
    }
    session.flush();  // ids pre-fetched from visit_seq
    return visits.stream().map(Visit::getId).toList();
});
1
2
3
4
5
var insert = ctx.insertInto(VISIT, VISIT.PET_ID, VISIT.VISIT_DATE, VISIT.DESCRIPTION);
for (int i = 0; i < BATCH_SIZE; i++) {
    insert = insert.values(..., ..., ...);  // one VALUES tuple appended per row
}
return insert.returning(VISIT.ID).fetch().map(r -> r.get(VISIT.ID));
1
2
3
4
5
Visits.batchInsert(0 until BATCH_SIZE, shouldReturnGeneratedValues = true) { i ->
    this[Visits.petId] = ...
    this[Visits.visitDate] = ...
    this[Visits.description] = ...
}.map { it[Visits.id] }  // Exposed batchInsert = JDBC addBatch, not multi-row VALUES
1
2
3
4
val daos = (0 until BATCH_SIZE).map {
    VisitDao.new { petId = ...; visitDate = ...; description = ... }
}
daos.map { it.id.value }  // reading ids flushes the pending inserts as a batch
1
2
3
4
5
6
7
8
database.useTransaction {
    // bulkInsertReturning (ktorm-support-postgresql): one multi-row INSERT … RETURNING
    database.bulkInsertReturning(Visits, Visits.id) {
        for (i in 0 until BATCH_SIZE) {
            item { set(it.petId, …); set(it.visitDate, …); set(it.description, …) }
        }
    }
}
1
2
3
4
5
List<Visit> drafts = ...;  // 100x VisitDraft.$.produce(d -> { d.setPet(makeIdOnly(..)); .. })
sqlClient.getEntities()
    .saveEntitiesCommand(drafts)
    .setMode(SaveMode.INSERT_ONLY)
    .execute(connection);  // JDBC batch, keys from RETURNING
generated sql
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?), (?, ?, ?), ...  -- 100 tuples, one statement
RETURNING id
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?), (?, ?, ?), ...  -- 100 tuples, one statement
RETURNING id
SELECT nextval('visit_seq')  -- pooled sequence, ~2 calls for 100 rows

INSERT INTO visit (pet_id, visit_date, description, id)
VALUES (?, ?, ?, ?)  -- batched x100, ids assigned client-side
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?), (?, ?, ?), ...  -- 100 tuples, one statement
RETURNING id
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- one row per statement, sent as a single JDBC batch of 100
RETURNING *  -- appended by the driver to serve getGeneratedKeys
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- one row per statement, sent as a single JDBC batch of 100
RETURNING *  -- appended by the driver to serve getGeneratedKeys
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?), (?, ?, ?), ...  -- 100 tuples, one statement
RETURNING id
INSERT INTO visit (pet_id, visit_date, description)
VALUES (?, ?, ?)  -- one row per statement, sent as a single JDBC batch of 100
RETURNING id  -- keys collected per batched statement
Keyset pagination

One page of twenty rows through seek pagination: filter past the cursor, order by the key, stop after a page. Storm's scroll terminal fetches one extra row to detect whether a next page exists, and inlines the page size as a literal. That literal matters more than it looks: the execution plan is the same either way, but when the page size arrives as a bind parameter PostgreSQL never adopts a cached generic plan and replans the three-table join on every call, which costs about as much as executing it. Every implementation that can express a literal page size does: Storm and JDBC by construction, Exposed's limit, Hibernate's HQL limit clause, jOOQ's DSL.inline. Ktorm's take has no literal form and pays that planning pass, and the fetcher libraries load owner and city in follow-up batched queries, paying round trips instead.

keyset Kotlin
1
2
// Seek past the cursor, one page deep; Pet, Owner and City hydrate from one query.
val page = pets.scroll(Scrollable.of(Pet_.id, cursor, PAGE_SIZE)).content
1
2
3
4
5
6
7
8
try (var ps = connection.prepareStatement("""
        SELECT p.id, p.name, … , c.id, c.name FROM pet p
        JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id
        WHERE p.id > ? ORDER BY p.id
        LIMIT %d""".formatted(PAGE_SIZE))) {  // literal LIMIT: PostgreSQL settles on a cached plan
    ps.setLong(1, cursor);
    // execute and map each row into Pet, Owner and City by hand
}
1
2
3
4
5
6
7
8
// The HQL limit clause inlines the constant page size, so PostgreSQL caches the generic plan;
// setMaxResults would bind it and force a fresh planning pass on every call.
return sessionFactory.fromSession(session -> session
        .createSelectionQuery(
                "from Pet p join fetch p.owner o join fetch o.city where p.id > :cursor order by p.id limit 20",
                Pet.class)
        .setParameter("cursor", cursor)
        .getResultList());
1
2
3
4
5
6
return ctx.select(PET.ID, PET.NAME, PET.BIRTH_DATE, PET.TYPE_ID,
                row(OWNER.ID, … , row(CITY.ID, CITY.NAME).mapping(City::new)).mapping(Owner::new))
        .from(PET).join(OWNER).on(…).join(CITY).on(…)
        .orderBy(PET.ID).seek(cursor)
        .limit(inline(PAGE_SIZE))  // inlined constant: PostgreSQL caches the generic plan
        .fetch(Records.mapping(Pet::new));
1
2
3
4
5
6
7
8
transaction(database) {
    (Pets innerJoin Owners innerJoin Cities)
        .selectAll()
        .where { Pets.id greater cursor }
        .orderBy(Pets.id)
        .limit(PAGE_SIZE)
        .map { it.toPet() }
}
1
2
3
4
5
6
7
transaction(database) {
    PetDao.wrapRows(Pets.selectAll()
            .where { Pets.id greater cursor }
            .orderBy(Pets.id to SortOrder.ASC).limit(PAGE_SIZE))
        .with(PetDao::owner, OwnerDao::city)  // eager-loads in batched queries
        .map { it.toPet() }
}
1
2
3
4
5
database.sequenceOf(Pets)
    .filter { Pets.id greater cursor }
    .sortedBy { Pets.id }
    .take(PAGE_SIZE)
    .toList()  // reference bindings join owner and city
1
2
3
4
5
6
7
8
return sqlClient.createQuery(table)
        .where(table.id().gt(cursor))
        .orderBy(table.id().asc())
        .select(table.fetch(PetFetcher.$.allScalarFields()
                .owner(OwnerFetcher.$.allScalarFields()
                        .city(CityFetcher.$.allScalarFields()))))
        .limit(PAGE_SIZE)
        .execute();
generated sql
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE p.id > ?
ORDER BY p.id
LIMIT 21  -- page size + 1 detects a next page; the literal count lets PostgreSQL cache the plan
SELECT-- the same three-table join
WHERE p.id > ?
ORDER BY p.id
LIMIT 20  -- inlined literal: PostgreSQL settles on a cached generic plan
SELECT-- the same three-table join
WHERE p.id > ?
ORDER BY p.id
FETCH FIRST 20 ROWS ONLY  -- the HQL limit clause renders the constant: cached generic plan
SELECT-- the same three-table join
WHERE p.id > ?
ORDER BY p.id
FETCH NEXT 20 ROWS ONLY  -- DSL.inline renders the constant: cached generic plan
SELECT-- the same three-table join
WHERE p.id > ?
ORDER BY p.id
LIMIT 20  -- inlined literal: PostgreSQL settles on a cached generic plan
-- 1) one page of pets
SELECT pet.id, pet.name, pet.birth_date, pet.type_id, pet.owner_id
FROM pet WHERE pet.id > ? ORDER BY pet.id LIMIT 20

-- 2) batched owners, 3) batched cities
SELECTFROM owner WHERE owner.id IN (?, …)
SELECTFROM city WHERE city.id IN (?, …)
SELECTFROM pet
LEFT JOIN owner _ref0 ON pet.owner_id = _ref0.id  -- reference bindings join with LEFT JOIN
LEFT JOIN city _ref1 ON _ref0.city_id = _ref1.id
WHERE pet.id > ?
ORDER BY pet.id
LIMIT ?  -- bound page size: replanned on every execution
SELECTFROM pet WHERE id > ? ORDER BY id LIMIT ?
-- then the fetcher loads the associations in batched queries:
SELECTFROM owner WHERE id = ANY(?)
SELECTFROM city WHERE id = ANY(?)
Three-table join

No fetch joins to spell out and no N+1 to dodge: the entity graph declares what a Pet is, so selecting pets hydrates owner and city from one query. When reading the three sizes, note that the bound range predicate races PostgreSQL's plan-cache decision, which follows the bind values: 10-row spans keep every implementation on per-call custom planning, while 100-row spans sit at the custom-versus-generic cost crossover and can settle either way per statement, which is why the 100-row column carries a plan-regime component on top of framework overhead (a baseline faster at 100 rows than at 10 is the cached-plan signature). The 1,000-row join, where the regimes converge, is the cleanest read of per-row mapping cost.

joinWithMapping Kotlin
1
2
3
val result = pets.select()
    .where((Pet_.id greater base) and (Pet_.id lessEq base + rows))
    .resultList  // Pet, Owner and City hydrated from one query
1
2
3
4
5
6
7
8
9
10
11
try (var ps = connection.prepareStatement(
        "SELECT p.id, p.name, ..., c.id, c.name FROM pet p"
        + " JOIN owner o ON p.owner_id = o.id JOIN city c ON o.city_id = c.id"
        + " WHERE p.id > ? AND p.id <= ?")) {
    ps.setLong(1, base); ps.setLong(2, base + rows);
    try (var rs = ps.executeQuery()) {
        List<Pet> pets = new ArrayList<>();
        while (rs.next()) pets.add(mapPet(rs));  // hand-map each row -> Pet, Owner, City
        return pets;
    }
}
1
2
3
4
5
6
7
return sessionFactory.fromSession(session -> session
    .createSelectionQuery(
        "from Pet p join fetch p.owner o join fetch o.city where p.id > :base and p.id <= :top",
        Pet.class)
    .setParameter("base", base)
    .setParameter("top", base + rows)
    .getResultList());
1
2
3
4
5
6
7
8
9
return ctx.select(
        PET.ID, PET.NAME, PET.BIRTH_DATE, PET.TYPE_ID,
        row(OWNER.ID, OWNER.FIRST_NAME, OWNER.LAST_NAME, OWNER.ADDRESS, OWNER.TELEPHONE,
                row(CITY.ID, CITY.NAME).mapping(City::new)).mapping(Owner::new))
    .from(PET)
    .join(OWNER).on(PET.OWNER_ID.eq(OWNER.ID))
    .join(CITY).on(OWNER.CITY_ID.eq(CITY.ID))
    .where(PET.ID.gt(base).and(PET.ID.le(base + rows)))
    .fetch(Records.mapping(Pet::new));
1
2
3
4
5
6
transaction(database) {
    (Pets innerJoin Owners innerJoin Cities)
        .selectAll()
        .where { (Pets.id greater base) and (Pets.id lessEq base + rows) }
        .map { it.toPet() }
}
1
2
3
4
5
6
7
transaction(database) {
    PetDao.wrapRows(
        Pets.selectAll()
            .where { (Pets.id greater base) and (Pets.id lessEq base + rows) })
        .with(PetDao::owner, OwnerDao::city)  // 2 extra batched SELECT ... IN queries
        .map { it.toPet() }
}
1
2
3
database.sequenceOf(Pets)
    .filter { (Pets.id greater base) and (Pets.id lessEq base + rows) }
    .toList()  // reference bindings join owner and city
1
2
3
4
5
6
7
8
PetTable table = PetTable.$;
return sqlClient.createQuery(table)
    .where(table.id().gt(base)).where(table.id().le(base + rows))
    .select(table.fetch(
        PetFetcher.$.allScalarFields()
            .owner(OwnerFetcher.$.allScalarFields()
                .city(CityFetcher.$.allScalarFields()))))  // fetchers -> 2 batched loads
    .execute();
generated sql
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE p.id > ? AND p.id <= ?
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id,
       o.first_name, o.last_name, o.address, o.telephone, o.city_id, c.name
FROM pet p
INNER JOIN owner o ON p.owner_id = o.id
INNER JOIN city c ON o.city_id = c.id
WHERE p.id > ? AND p.id <= ?
-- 1) main pet query
SELECT p.id, p.name, p.birth_date, p.type_id, p.owner_id
FROM pet p WHERE p.id > ? AND p.id <= ?

-- 2) batched owners
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o WHERE o.id IN (?, ?, ...)

-- 3) batched cities
SELECT c.id, c.name FROM city c WHERE c.id IN (?, ?, ...)
SELECT-- all pet, owner and city columns
FROM pet
LEFT JOIN owner _ref0 ON pet.owner_id = _ref0.id  -- reference bindings join with LEFT JOIN
LEFT JOIN city _ref1 ON _ref0.city_id = _ref1.id
WHERE (pet.id > ?) AND (pet.id <= ?)
-- 1) main pet query (owner_id kept as FK for the batch load)
SELECT p.id, p.name, p.birth_date, p.owner_id
FROM pet p WHERE p.id > ? AND p.id <= ?

-- 2) batched owners
SELECT o.id, o.first_name, o.last_name, o.address, o.telephone, o.city_id
FROM owner o WHERE o.id = ANY(?)  -- array bind, chunked for large id sets

-- 3) batched cities
SELECT c.id, c.name FROM city c WHERE c.id = ANY(?)

Methodology and reproduction

Scope and limitations

These benchmarks measure single-threaded operation latency on PostgreSQL. They do not measure application throughput, connection-pool contention, startup time, memory use, native-image performance or behaviour on other databases.

Every rule below is enforced by the harness code, not just described here.

  • Real round trips. One tuned PostgreSQL 17 container, reached over TCP. On the published runner the bare SELECT 1 baseline measured about 84 µs, and every score includes it. That compresses relative differences; the mapping-heavy workloads are where library differences show.
  • JMH, properly. Five forks, five 3-second measurement iterations after warmup, single thread: latency, not throughput. Each figure is the median of the five forks, with the fork range, fastest to slowest, in brackets alongside. The median is robust to a fork drawing an unlucky GC, scheduling or JIT profile, and does not reward implementations whose forks vary more; the raw per-fork data is committed with each run, so any other estimator can be recomputed from the same artifacts. Sanity checks run every workload once per trial and verify row counts before anything is timed.
  • Same work for everyone. Identical schema and data, identical transaction boundaries on writes, and update values derived from the value just read, so change-detecting libraries can never silently skip a write. On the update workload every implementation reads a lazy association shape and issues a single UPDATE.
  • The fastest documented setup for everyone. Each library runs the most performant solution its ecosystem documents: its own modules and configuration are allowed, the benchmark code stays within its API, and the JDBC driver and database run stock for everyone, so the suite measures the ORM rather than driver tuning. Every optimization must be documented, recommended by production guidance, and free of semantic penalty for its workload; best practice takes precedence over raw speed. Each application is tracked in the optimizations table.
  • Extra shapes count as query code. A library may define a purpose-built entity shape for a workload on top of its regular entity for that type, held to the same bar as any optimization: documented features, recommended practice, no semantic penalty. Because such a shape exists to speed a query, its lines count toward the query comparison rather than the model comparison.
  • Rows are the unit of comparison. Libraries within one chart ran in the same session under the same conditions. Comparing across charts, or treating values as absolute costs, carries environment drift that comparing within a chart does not.

Versions: Storm 1.14.0, Hibernate 7.4.7, jOOQ 3.21.7, Exposed 1.5.0, Ktorm 4.2.1, Jimmer 0.11.7, PostgreSQL 17, JDK 21.

Reproducing these numbers

The published figures come from the repository's benchmark GitHub Actions workflow on a GitHub-hosted dedicated runner (dedicated, 4 vCPU, 16 GB, Ubuntu 24.04), which builds Storm from source at the commit stated with each run, executes the full suite against PostgreSQL 17 in Docker, and uploads the results as an artifact. The raw per-fork JMH data, the merged tables and a metadata file recording the exact versions, runner and JMH configuration are committed under results/ in the repository, so every published number can be recomputed from its artifacts.

To reproduce: fork the repository and dispatch the benchmark workflow, choosing the Storm ref to build, the runner label and the mode; a comparable dedicated runner class gives comparable stability. Or run the suite locally with JDK 21 and Docker: scripts/run.sh starts one tuned container and runs every module. Absolute numbers depend on the hardware; the comparison within a table is the point.

$git clone https://github.com/storm-orm/storm-benchmarks.git
View on GitHub →