writing / Oct 18, 2026 · 6 min
178 queries, no ORM: why Pull Up uses sqlc
The query behind the map is PostGIS, three lateral joins and thirteen optional filters. An ORM would have turned it into a raw-SQL string. sqlc kept it as SQL and still gave me types.
Pull Up’s Go server runs 178 queries against PostgreSQL. None of them were
written by an ORM. Each one is a plain .sql file, and
sqlc turns it into a typed Go function. This post explains
that choice, and what it costs.
The query that decided it
The main screen of the app is a map of nearby courts, each with a live headcount. Here is a trimmed version of the query behind it:
-- name: CourtsNearby :many
SELECT
c.id, c.name,
ST_Y(c.location::geometry)::float8 AS lat,
ST_X(c.location::geometry)::float8 AS lng,
ST_Distance(c.location, ST_SetSRID(ST_MakePoint(sqlc.arg(lng)::float8, sqlc.arg(lat)::float8), 4326)::geography)::float8 AS distance_m,
ac.active_count
FROM courts c
LEFT JOIN LATERAL (
SELECT coalesce(sum(ci.party_size), 0)::int AS active_count
FROM check_ins ci
WHERE ci.court_id = c.id AND ci.checked_out_at IS NULL AND ci.expires_at > now()
) ac ON true
WHERE c.status <> 'rejected'
AND ST_DWithin(c.location, ST_SetSRID(ST_MakePoint(sqlc.arg(lng)::float8, sqlc.arg(lat)::float8), 4326)::geography, sqlc.arg(radius_m)::float8)
AND (sqlc.narg('indoor')::bool IS NULL OR c.indoor = sqlc.narg('indoor'))
AND (sqlc.narg('lit')::bool IS NULL OR c.lighting = sqlc.narg('lit'))
ORDER BY distance_m
LIMIT 100;
The full version has two more lateral joins (the latest crowd report, the next scheduled run) and thirteen optional filters.
None of that is unusual for a map product, but almost none of it is what an
ORM is good at. ST_DWithin and ST_Distance are PostGIS functions. The
lateral joins compute one aggregate per court. In most Go ORMs this query
ends up as a raw-SQL string passed to an escape hatch, with the results
scanned by hand. That gives you the ORM’s complexity without its benefits.
With sqlc, the SQL stays in charge, and Go gets the types.
What comes back
sqlc reads the query, checks it against the schema built from my goose migrations, and generates this:
type CourtsNearbyParams struct {
Lng float64 `json:"lng"`
Lat float64 `json:"lat"`
RadiusM float64 `json:"radius_m"`
Indoor *bool `json:"indoor"`
Lit *bool `json:"lit"`
// ...eleven more filters
}
The required arguments (sqlc.arg) are plain values. The optional filters
(sqlc.narg) are pointers, and a nil pointer means “don’t filter”. The SQL
uses the same rule: narg IS NULL OR .... The query and the struct agree on
what “optional” means, and the agreement is generated, not maintained by hand.
The bigger win is what happens when the schema changes. If a migration
renames a column and a query still uses the old name, sqlc generate fails.
If a column becomes nullable, the generated field becomes a pointer and every
caller that assumed it was always set stops compiling. I find out when I
regenerate, not from a 500 in production.
The data rules live where you can read them
Courts are seeded from OpenStreetMap and then corrected by players. Re-importing from OpenStreetMap must not wipe out what a player typed in. That rule lives in the upsert, next to the comment that explains it:
ON CONFLICT (osm_type, osm_id) DO UPDATE SET
name = EXCLUDED.name,
location = EXCLUDED.location,
-- Coalesced, unlike the columns above: a re-import of a court whose OSM
-- charge=* tag is missing or unparseable must not wipe a price a player
-- typed in.
fee_amount_cents = coalesce(EXCLUDED.fee_amount_cents, courts.fee_amount_cents),
fee_currency = coalesce(EXCLUDED.fee_currency, courts.fee_currency),
In a code review, that is the line that matters, and here it is visible on its own. With an ORM, the same rule would be spread across a model hook or a conditional in the Go code that builds the update.
What it costs
sqlc does not know about PostGIS. It type-checks queries against your schema,
and it ignores CREATE EXTENSION, so ST_DWithin looks like a function that
does not exist. The fix is a shim file that only sqlc ever reads:
-- sqlc-only schema shims. NEVER run against a real database.
CREATE TYPE geography AS (dummy int);
CREATE FUNCTION ST_DWithin(geography, geography, float8) RETURNS boolean AS 'SELECT NULL' LANGUAGE sql;
CREATE FUNCTION ST_Distance(geography, geography) RETURNS float8 AS 'SELECT NULL' LANGUAGE sql;
It works, but it is a hand-written copy of PostGIS signatures. If I get a signature wrong, sqlc trusts the shim, not PostGIS, and the mistake only shows up when the query runs. Every new PostGIS function means one more line in that file. My integration tests run against a real PostGIS database for exactly this reason.
The other cost is dynamic queries. sqlc generates one function per query, so
there is no building a WHERE clause piece by piece. The narg IS NULL OR
pattern is how the map’s thirteen filters fit into one query. It works well,
but the query gets long, and it would not scale to a search screen where users
can combine fields freely.
When I would choose an ORM
If the product were mostly CRUD screens over many tables, or depended on queries assembled at runtime, I would pick differently. The time an ORM saves is real when most queries are simple.
Pull Up is not like that. Its most important queries are spatial, use
aggregates, and are full of rules about whose data wins. For those, I want
to read the SQL in review, run it in psql, and have the compiler tell me
when the schema has moved underneath it. sqlc gives me all three.