head2head · jimmyhoughjr.net
Two implementations of one AssignmentStore protocol, run against the same Postgres 16.15 doing the same work. Three tables with real keys, one join with an aggregate, one ordered read with a limit, and an idempotent save. Measured September 4, 2026.
Both stores write the same rows. The check is a row count in the database, not a test reporting success: 7 assignments, 15 seats and 20 events from each implementation. The join, the aggregate and the limited read return identical results. Nothing below is about whether one of them works.
The build times are the weakest line here. At this size 1.1 seconds is inside the noise of a warm build, and the difference should decide nothing. It is on the page because it was measured, not because it means anything.
| Metric | Raw SQL | FluentKit | Edge |
|---|---|---|---|
| Assignments written | 7 | 7 | tie |
| Seats written | 15 | 15 | tie |
| Events written | 20 | 20 | tie |
| Lines of implementation | 158 | 246 | raw |
| Packages added | postgres-nio | fluent-kit + driver (~10) | raw |
| Packages in the build, both stores | 27 | 27 | — |
| Target build, warm | 5.7 s | 4.6 s | tie |
| Round trips per idempotent save | 1 | 2 | raw |
| Grouped aggregate | GROUP BY | loads children, counts in Swift | raw |
| Renamed column caught | when it runs | when it compiles | Fluent |
| Schema stated | once | twice (model + migration) | raw |
| Migration support | CREATE TABLE IF NOT EXISTS | migration framework | Fluent |
The first framing was unfair, and it is worth recording. An append-only ledger is Fluent's worst case: one record type, written once, replayed in order, with no relationships to model. A comparison on that slice would have proved only that the wrong tool loses. So the slice here is deliberately relational.
The second unfairness was also the author's. Depending on fluent rather than fluent-kit pulled Vapor, console-kit, websocket-kit and async-http-client into a Hummingbird service, for a total of 36 packages. fluent is the Vapor integration and fluent-kit is the ORM. With the right one it is 27 packages and no Vapor. The 36 would have read as a real finding about Fluent. It was a mistake in the Package.swift.
An idempotent save is one statement or two round trips. In SQL it is INSERT … ON CONFLICT (id) DO UPDATE. Fluent has no upsert, so it is find, then a branch to create or update. That is two round trips instead of one, on a path a control plane takes constantly.
Fluent has no grouped aggregate. "Assignments awaiting a person, and how many seats each ran" is one GROUP BY in SQL. Fluent loads the assignments with their children and counts them in Swift. That is a different query. It is fine for a board and wrong for a table with a million seats, and the two versions of the code do not look different enough to warn you.
Fluent catches a renamed column and raw does not. A key path is checked by the compiler. A string inside a SQL literal is checked when it runs, in production. This is Fluent's strongest argument and it is a real one.
But Fluent says the schema twice, so it can still drift. A model and its migration can disagree, and nothing notices until a query fails. That is the one place where the type safety gives itself back. Those 88 extra lines are almost entirely the shape said a second time.
Fluent ships a real migration framework. It tracks what has run in a _fluent_migrations table. Raw here is CREATE TABLE IF NOT EXISTS, which covers a restart and nothing else. For a schema that will change repeatedly with several people touching it, this is the argument that matters most.
Fluent leaves an async edge or two. Databases.shutdown() is unavailable from an async context and has to be shutdownAsync(). It is small, but it is the kind of seam an ORM built for an older concurrency model leaves behind.
Raw, for rookery as it stands. The core is an append-only event log with one author, and the schema changed shape three times in a day. ON CONFLICT and a real GROUP BY matter more here than compile-checked column names, and the SQL is readable by anyone who knows SQL rather than by anyone who knows Fluent.
Fluent, if the schema is going to evolve under several hands. The migration framework and the compile-time column checking are worth 88 lines and ten packages when the alternative is a renamed column found in production.
The seam is a protocol, so this door does not close. Both implementations stay in the repository and the same test exercises both, which is the only way the comparison keeps being true.
Methodology: both stores run the same test against one Postgres 16.15 instance, and agreement is checked by counting rows in the database afterwards. Lines are the implementation files only. Package counts come from the resolved dependency graph. Build times are single warm swift build wall clocks on one machine, and they are directional at best. Built by Jimmy Hough Jr & Claude · Donations appreciated · Powered by Roost.