head2head · jimmyhoughjr.net

rookery: raw SQL vs Fluent

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.

Agreement
identical
7 assignments · 15 seats · 20 events
Lines of implementation
−36%
raw · 158 vs 246
Idempotent save
1 vs 2
raw · round trips
Warm target build
≈ tie
5.7 vs 4.6 s, inside the noise

They agree

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 numbers

Raw SQL / PostgresNIO FluentKit
Lines of implementation
158
246
Packages added
1 · postgres-nio
~10 · fluent-kit + driver
Round trips per idempotent save
1
2
Target build, warm
5.7 s
4.6 s

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.

Everything measured

MetricRaw SQLFluentKitEdge
Assignments written77tie
Seats written1515tie
Events written2020tie
Lines of implementation158246raw
Packages addedpostgres-niofluent-kit + driver (~10)raw
Packages in the build, both stores2727
Target build, warm5.7 s4.6 stie
Round trips per idempotent save12raw
Grouped aggregateGROUP BYloads children, counts in Swiftraw
Renamed column caughtwhen it runswhen it compilesFluent
Schema statedoncetwice (model + migration)raw
Migration supportCREATE TABLE IF NOT EXISTSmigration frameworkFluent

Two corrections that make this fair

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.

Where they actually differ

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.

Verdict

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.