Concise by design.
Fast by measurement.
Storm set out to be the most enjoyable ORM to work with, entities as plain records, queries that read like the SQL they produce. This page shows that the same design keeps the hot path lean, measured against six alternatives on identical workloads, with the code behind every number.
-
PostgreSQL 17 over TCP · JMH · Storm 1.13.0 · measured 2026-07-14
+
PostgreSQL 17 over TCP · JMH · Storm 1.13.0 · measured 2026-07-16
-
+10 µsis all Storm adds to a 172 µs primary key lookup over raw JDBC. The abstraction is nearly free.
-
26% lessoverhead over raw JDBC than the closest alternative, averaged across all eight workloads.
-
72% lessentity code than JPA: the five-table model is 29 lines in Storm, 105 as JPA entities.
+
5 of 8workloads where Storm is the fastest ORM.
+
5.6%is the most Storm trails the fastest ORM on any workload.
+
62% loweroverhead than jOOQ (2nd fastest ORM) relative to raw JDBC.
At a glance
-
Seven implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. Mean latency per operation, lower is better. Cells are tinted by distance from the fastest framework in the row, green through red. Percentages are overhead over raw JDBC. Raw JDBC is the reference floor.
+
Seven implementations, one database, one discipline: same schema, same data, same transaction boundaries, every score a real network round trip away from PostgreSQL. Mean latency per operation, lower is better. Cells are tinted by distance from the fastest framework in the row, green through red. Percentages are overhead over raw JDBC. Raw JDBC is the baseline.
${matrixHtml()}
-
Each library excels in certain areas. Storm has the lowest average measured overhead over raw JDBC across these eight workloads, while individual workloads favor different libraries: raw JDBC is fastest everywhere by construction, and Exposed's DSL edges Storm on the 1,000-row three-table join and the read-modify-update workload. Across the suite Storm is consistently the fastest full framework or within a third of it. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.
+
Storm is the fastest framework on average across the eight workloads. On a few, another library edges it: Hibernate on the single-row lookup and the read-modify-update, jOOQ on the batch insert, where its single multi-row statement beats the batched single-row insert the others send. Even then, Storm stays within about six percent of the fastest framework. A real network round trip sits inside every score, so the pure mapping gap is larger still. Absolute times depend on the hardware they were measured on; the relative comparisons are the point.
Per-workload charts: the same numbers with their reported error
@@ -440,44 +964,96 @@ ${charts}
The code being measured
-
Numbers without code invite tuned-benchmark suspicion, so here is what each workload runs, trimmed of harness plumbing. Toggle Show SQL to see the exact statement Storm puts on the wire. The full sources for all seven implementations are in the benchmark repository.
+
Numbers without code invite tuned-benchmark suspicion, so here is what each workload runs in every library, trimmed of harness plumbing. Pick a library from the selector; toggle Show SQL to see the exact statement it puts on the wire. The full sources for all seven implementations are in the benchmark repository.
${modelLocHtml()}
${locHtml()}
The model
-
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. There are no proxies, no session lifecycle, and nothing to configure.
- ${editor({file: 'Entities.kt', tag: 'Kotlin', code: CODE_ENTITIES})}
+
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. Switch the selector to compare it against the JPA entities, table objects and interfaces the other libraries declare for the same five tables.
+ ${editor({variants: [
+ {label: 'Storm', file: 'Entities.kt', tag: 'Kotlin', code: CODE_ENTITIES, selected: true},
+ {label: 'JDBC', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JDBC},
+ {label: 'Hibernate', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_HIBERNATE},
+ {label: 'jOOQ', file: 'Models.java', tag: 'Java', code: CODE_MODEL_JOOQ},
+ {label: 'Exposed', file: 'Tables.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED},
+ {label: 'Exposed DAO', file: 'Entities.kt', tag: 'Kotlin', code: CODE_MODEL_EXPOSED_DAO},
+ {label: 'Jimmer', file: 'Entities.java', tag: 'Java', code: CODE_MODEL_JIMMER},
+ ]})}
Primary key lookup
- ${editor({file: 'singleRowById', tag: 'Kotlin', code: CODE_SINGLE, sql: SQL_SINGLE})}
+ ${editor({file: 'singleRowById', sql: SQL_SINGLE, variants: [
+ {label: 'Storm', tag: 'Kotlin', code: CODE_SINGLE, selected: true},
+ {label: 'JDBC', tag: 'Java', code: CODE_SINGLE_JDBC},
+ {label: 'Hibernate', tag: 'Java', code: CODE_SINGLE_HIBERNATE},
+ {label: 'jOOQ', tag: 'Java', code: CODE_SINGLE_JOOQ},
+ {label: 'Exposed', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED},
+ {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_SINGLE_EXPOSED_DAO},
+ {label: 'Jimmer', tag: 'Java', code: CODE_SINGLE_JIMMER},
+ ]})}
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.
- ${editor({file: 'joinWithMapping', tag: 'Kotlin', code: CODE_JOIN, sql: SQL_JOIN})}
+ ${editor({file: 'joinWithMapping', sql: SQL_JOIN, variants: [
+ {label: 'Storm', tag: 'Kotlin', code: CODE_JOIN, selected: true},
+ {label: 'JDBC', tag: 'Java', code: CODE_JOIN_JDBC},
+ {label: 'Hibernate', tag: 'Java', code: CODE_JOIN_HIBERNATE},
+ {label: 'jOOQ', tag: 'Java', code: CODE_JOIN_JOOQ},
+ {label: 'Exposed', tag: 'Kotlin', code: CODE_JOIN_EXPOSED},
+ {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_JOIN_EXPOSED_DAO, sql: SQL_JOIN_EXPOSED_DAO},
+ {label: 'Jimmer', tag: 'Java', code: CODE_JOIN_JIMMER, sql: SQL_JOIN_JIMMER},
+ ]})}
Projection
A template picks three columns across the graph; the metamodel keeps every path compile-checked.
- ${editor({file: 'projection', tag: 'Kotlin', code: CODE_PROJECTION, sql: SQL_PROJECTION})}
+ ${editor({file: 'projection', sql: SQL_PROJECTION, variants: [
+ {label: 'Storm', tag: 'Kotlin', code: CODE_PROJECTION, selected: true},
+ {label: 'JDBC', tag: 'Java', code: CODE_PROJECTION_JDBC},
+ {label: 'Hibernate', tag: 'Java', code: CODE_PROJECTION_HIBERNATE},
+ {label: 'jOOQ', tag: 'Java', code: CODE_PROJECTION_JOOQ},
+ {label: 'Exposed', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED},
+ {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_PROJECTION_EXPOSED_DAO},
+ {label: 'Jimmer', tag: 'Java', code: CODE_PROJECTION_JIMMER},
+ ]})}
Batch insert
- ${editor({file: 'batchInsert', tag: 'Kotlin', code: CODE_BATCH, sql: SQL_BATCH})}
+ ${editor({file: 'batchInsert', variants: [
+ {label: 'Storm', tag: 'Kotlin', code: CODE_BATCH, sql: SQL_BATCH, selected: true},
+ {label: 'JDBC', tag: 'Java', code: CODE_BATCH_JDBC, sql: SQL_BATCH},
+ {label: 'Hibernate', tag: 'Java', code: CODE_BATCH_HIBERNATE, sql: SQL_BATCH_HIBERNATE},
+ {label: 'jOOQ', tag: 'Java', code: CODE_BATCH_JOOQ, sql: SQL_BATCH_JOOQ},
+ {label: 'Exposed', tag: 'Kotlin', code: CODE_BATCH_EXPOSED, sql: SQL_BATCH},
+ {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_BATCH_EXPOSED_DAO, sql: SQL_BATCH},
+ {label: 'Jimmer', tag: 'Java', code: CODE_BATCH_JIMMER, sql: SQL_BATCH},
+ ]})}
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.
- ${editor({file: 'updateById', tag: 'Kotlin', code: CODE_UPDATE, sql: SQL_UPDATE})}
+ ${editor({file: 'updateById', sql: SQL_UPDATE, variants: [
+ {label: 'Storm', tag: 'Kotlin', code: CODE_UPDATE, selected: true},
+ {label: 'JDBC', tag: 'Java', code: CODE_UPDATE_JDBC},
+ {label: 'Hibernate', tag: 'Java', code: CODE_UPDATE_HIBERNATE},
+ {label: 'jOOQ', tag: 'Java', code: CODE_UPDATE_JOOQ},
+ {label: 'Exposed', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED},
+ {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_UPDATE_EXPOSED_DAO},
+ {label: 'Jimmer', tag: 'Java', code: CODE_UPDATE_JIMMER},
+ ]})}
Object graph
One query, grouped during hydration. Repeated owners deduplicate to the same instance, so grouping is an identity operation, not a hash of every field. Switch the variant to see the code the other libraries needed for the same result.
- ${editor({file: 'objectGraph', tag: 'Kotlin', sql: SQL_GRAPH, variants: [
- {label: 'Storm', code: CODE_GRAPH_STORM, selected: true},
- {label: 'Hibernate', code: CODE_GRAPH_HIBERNATE},
- {label: 'jOOQ', code: CODE_GRAPH_JOOQ},
+ ${editor({file: 'objectGraph', sql: SQL_GRAPH, variants: [
+ {label: 'Storm', tag: 'Kotlin', code: CODE_GRAPH_STORM, selected: true},
+ {label: 'JDBC', tag: 'Java', code: CODE_GRAPH_JDBC},
+ {label: 'Hibernate', tag: 'Java', code: CODE_GRAPH_HIBERNATE, sql: SQL_GRAPH_HIBERNATE},
+ {label: 'jOOQ', tag: 'Java', code: CODE_GRAPH_JOOQ, sql: SQL_GRAPH_JOOQ},
+ {label: 'Exposed', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED},
+ {label: 'Exposed DAO', tag: 'Kotlin', code: CODE_GRAPH_EXPOSED_DAO, sql: SQL_GRAPH_EXPOSED_DAO},
+ {label: 'Jimmer', tag: 'Java', code: CODE_GRAPH_JIMMER, sql: SQL_GRAPH_JIMMER},
]})}
Method and fairness
The suite is built to be argued with. Everything below is enforced in code, not prose.
- - Real round trips. One tuned PostgreSQL 17 container, reached over TCP. The raw JDBC single round trip measured about 158 µs, and every score includes it. That compresses relative differences; the mapping-heavy workloads are where library differences show.
+ - Real round trips. One tuned PostgreSQL 17 container, reached over TCP. The raw JDBC single round trip measured about 155 µs, and every score includes it. That compresses relative differences; the mapping-heavy workloads are where library differences show.
- JMH, properly. Two forks, five 3-second measurement iterations after warmup, single thread: latency, not throughput. 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 writes only the changed column and reads a lazy association shape.
- Idiomatic code for everyone. Each library is written the way its documentation recommends: Hibernate with
join fetch and @DynamicUpdate, jOOQ with generated records and MULTISET, Jimmer with fetchers, Exposed in both DSL and DAO flavors.
diff --git a/website/src/pages/index.js b/website/src/pages/index.js
index 718f67c33..3d8597dfc 100644
--- a/website/src/pages/index.js
+++ b/website/src/pages/index.js
@@ -60,18 +60,12 @@ const CSS = `
.storm-home .card.bcard{padding:18px 22px}
.storm-home .bcard.bench{min-height:186px}
.storm-home .bcard{position:relative;overflow:hidden}
- .storm-home .bcard .bfront p{font-size:12.5px;line-height:1.5}
- .storm-home .bcard .bfront .ic{width:26px;height:26px;margin-bottom:8px}
- .storm-home .bcard .bfront h3{font-size:15px;margin-bottom:6px}
.storm-home .bcard .bback p{font-size:12.5px;line-height:1.5}
.storm-home .bcard .bback h3{font-size:15.5px;margin-bottom:7px}
.storm-home .bface{transition:opacity .55s ease,transform .55s ease}
.storm-home .bcard:nth-child(2) .bface{transition-delay:.12s}
.storm-home .bcard:nth-child(3) .bface{transition-delay:.24s}
- .storm-home .bfront{position:relative}
- .storm-home .bback{position:absolute;inset:0;padding:inherit;opacity:0;transform:translateY(10px);pointer-events:none}
- .storm-home .bcard.bench .bfront{position:absolute;inset:0;padding:inherit;opacity:0;transform:translateY(-10px);pointer-events:none}
- .storm-home .bcard.bench .bback{position:relative;inset:auto;padding:0;opacity:1;transform:none;pointer-events:auto}
+ .storm-home .bback{position:relative;inset:auto;padding:0;opacity:1;transform:none;pointer-events:auto}
.storm-home .bnum{font-size:42px;font-weight:800;line-height:1;margin-bottom:8px;letter-spacing:-.02em;background:linear-gradient(100deg,#feeeb0,#fbbf24 55%,#f59e0b);-webkit-background-clip:text;background-clip:text;color:transparent}
.storm-home .bback a{color:var(--accent);text-decoration:none;font-weight:600;white-space:nowrap}
.storm-home .bback a:hover{text-decoration:underline}
@@ -308,40 +302,25 @@ const BODY = `
-
-
-
-
Direct database control
-
Every query is explicit and predictable. Nested predicates and entity graphs compile to a single efficient query, eliminating accidental hidden N+1 queries.
-
+
-
-
-
+
17%
+
faster than Hibernate
+
Averaged across 8 workloads.
-
-
-
-
Stateless and Immutable
-
Immutable data classes. No proxies, no flush, no hidden state. What you see is what you get. Safe to cache, share, and serialize across every layer.
-
+
-
-
-
+
72%
+
fewer entity lines
+
The five-table model: 29 lines in Storm, 105 in Hibernate.
-
-
-
-
Type-safe and Injection-safe
-
Compile-time detection of column and type errors. Automatic conversion of interpolated values into bind parameters.
-
+
-
-
-
+
19%
+
fewer query lines
+
All eight workloads: 100 lines in Storm, 123 in Hibernate, with no query strings.
@@ -382,30 +361,30 @@ const BODY = `
export default function Home() {
useEffect(() => {
- // Pick your ORM: the feature cards flip to Storm-vs-X measured figures (run of 2026-07-14).
+ // Pick your ORM: the feature cards show Storm-vs-X measured figures (run of 2026-07-16).
const VS = {
hibernate: {
- speed: ['11%', 'faster than Hibernate', 'Averaged across 8 workloads.'],
+ speed: ['17%', 'faster than Hibernate', 'On average across 8 workloads.'],
entities: ['72%', 'fewer entity lines', 'The five-table model: 29 lines in Storm, 105 in Hibernate.'],
queries: ['19%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 123 in Hibernate, with no query strings.'],
},
jooq: {
- speed: ['7%', 'faster than jOOQ', 'Averaged across 8 workloads.'],
+ speed: ['15%', 'faster than jOOQ', 'On average across 8 workloads.'],
entities: ['29 lines', 'instead of manual mapping', 'jOOQ maps results by hand into DTOs; Storm turns one 29-line model into typed rows everywhere.'],
queries: ['24%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 131 in jOOQ, no hand-written row mapping.'],
},
exposed: {
- speed: ['18%', 'faster than Exposed', 'Averaged across 8 workloads.'],
+ speed: ['18%', 'faster than Exposed', 'On average across 8 workloads.'],
entities: ['43%', 'fewer entity lines', 'The five-table model: 29 lines in Storm, 51 lines of Exposed table objects and data classes.'],
queries: ['22%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 128 in Exposed, no hand-written row mapping.'],
},
exposedDao: {
- speed: ['39%', 'faster than Exposed DAO', 'Averaged across 8 workloads.'],
+ speed: ['38%', 'faster than Exposed DAO', 'On average across 8 workloads.'],
entities: ['58%', 'fewer entity lines', 'One data class per table in Storm; Exposed DAO needs the table object, the DAO class and a DTO.'],
queries: ['19%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 124 in Exposed DAO.'],
},
jimmer: {
- speed: ['30%', 'faster than Jimmer', 'Averaged across 8 workloads.'],
+ speed: ['25%', 'faster than Jimmer', 'On average across 8 workloads.'],
entities: ['47%', 'fewer entity lines', 'The five-table model: 29 lines of data classes in Storm, 55 lines of interfaces in Jimmer.'],
queries: ['31%', 'fewer query lines', 'All eight workloads: 100 lines in Storm, 144 in Jimmer.'],
},
@@ -417,14 +396,8 @@ export default function Home() {
};
const vsCards = [...document.querySelectorAll('.storm-home .bcard')];
const vsChips = [...document.querySelectorAll('.storm-home .vs-chips button')];
- let vsActive = null;
function applyVs(key) {
- vsActive = key;
vsChips.forEach((chip) => chip.classList.toggle('on', chip.dataset.vs === key));
- if (!key) {
- vsCards.forEach((card) => card.classList.remove('bench'));
- return;
- }
const data = VS[key];
vsCards.forEach((card) => {
const back = card.querySelector('.bback');
@@ -446,7 +419,7 @@ export default function Home() {
}, 7000);
vsChips.forEach((chip) => chip.addEventListener('click', () => {
clearInterval(vsAuto);
- applyVs(vsActive === chip.dataset.vs ? null : chip.dataset.vs);
+ applyVs(chip.dataset.vs);
}));
applyVs('hibernate');