Query Builder vs ORM: What's the Difference for ClickHouse?

ORMs manage entities, relations, and migrations for transactional databases. Query builders construct typed SQL. Here's why that distinction matters for ClickHouse, and where hypequery deliberately sits.

Short answer: an ORM maps database rows to objects in your language and manages their whole lifecycle — identity, change tracking, relations, migrations. A query builder does one narrower job: it constructs SQL programmatically, with types checking your column names, filters, and result shapes. ORMs earn their complexity on transactional (OLTP) databases where you load an entity, mutate it, and save it back. ClickHouse has no such workflow — it's columnar, append-heavy, and aggregation-first — so for ClickHouse you want a query builder, not an ORM.

That's why the established TypeScript ORMs never grew ClickHouse support, and why hypequery is deliberately a typed query builder plus schema codegen plus a semantic layer — not an ORM. If you want to see what that looks like against your own schema, the quick start gets you to generated types and a first query in a few minutes.

The rest of this post defines both terms precisely, shows the code, and explains why the distinction is architectural rather than a matter of taste.

What an ORM actually does

"Object-Relational Mapper" undersells it. A full ORM — Hibernate, Django ORM, Prisma, TypeORM — does four distinct jobs:

  1. Mapping. Each table becomes a class or model; each row becomes an object instance. ordersOrder, with typed fields.
  2. Identity and unit of work. The ORM tracks which objects you've loaded and which fields you've changed. When you save, it computes the minimal UPDATE statements and issues them, often inside a transaction. Load the same row twice and many ORMs hand you the same object (an identity map).
  3. Relations. You declare that an Order belongs to a Customer and has many OrderItems, then traverse those relations in code. The ORM turns traversal into joins or lazy-loaded queries.
  4. Migrations. The model definitions are the source of truth for schema; the ORM diffs them against the database and generates ALTER TABLE migrations.

A typical ORM workflow looks like this (TypeORM-style, against Postgres):

This is a genuinely good abstraction for OLTP: single-row reads by primary key, small transactional writes, normalized tables connected by foreign keys. The ORM's machinery — identity, dirty checking, cascading saves — maps directly onto how a transactional application manipulates data.

What a query builder does

A query builder skips all four of those jobs except a slice of the first. It doesn't manage objects, track changes, or own your schema. It gives you a fluent, typed API for constructing SQL, executing it, and typing the results.

Here's the SQL for a daily revenue rollup:

And the equivalent with hypequery's typed builder:

The value is different in kind from an ORM's. Nothing here is an "entity." No object is tracked. What you get instead:

  • Compile-time column checking. 'orders', 'total', 'status' are all validated against your generated schema types. Rename a column in ClickHouse, regenerate, and every stale reference fails to compile.
  • Typed results. The result rows are typed from the query itself — including ClickHouse's runtime quirks, like UInt64 and DateTime coming back as strings and Nullable(T) becoming T | null.
  • Safe parameterization. Filter values are bound as parameters, not string-concatenated.
  • Composability. You can build a query up conditionally — add a .where() per user-supplied filter, a tenant clause per request — which is exactly what runtime analytics endpoints need.

Kysely is the best-known general-purpose TypeScript query builder, and it's the closest neighbor to what hypequery's builder does — the difference is that Kysely targets transactional dialects while hypequery is ClickHouse-native, with LIMIT BY, FINAL, array joins, and time-bucketing as first-class methods. There's a detailed breakdown in hypequery vs Kysely.

Why analytical databases favor query builders

The ORM/query-builder split isn't primarily about developer preference. It falls out of the database's design. Three properties of ClickHouse (and columnar analytical stores generally) make the ORM abstraction a poor fit:

1. No transactional entity updates. The ORM's centerpiece — load object, mutate, save inside a transaction — assumes cheap single-row UPDATEs and ACID transactions. ClickHouse has neither in the OLTP sense: it's optimized for bulk appends, and mutations are heavyweight background operations, not a request-path primitive. An identity map and unit of work have nothing to manage. The dedup patterns you actually use — ReplacingMergeTree with FINAL, or an argMax query — are query-time concerns, which is builder territory (.final(), .argMax()), not entity territory.

2. Aggregation-first access patterns. ORMs are built around fetching entities: find, findOne, save. Analytics queries are built around collapsing millions of rows into a few: sums, distinct counts, percentiles, time buckets. An ORM has no natural vocabulary for

whereas that's a query builder's home turf. ORMs treat aggregation as an escape hatch (raw SQL, queryRaw); on ClickHouse, aggregation is the workload.

3. Denormalized tables, not relation graphs. ORMs shine at traversing normalized schemas via foreign keys. ClickHouse best practice pushes the opposite way: wide, denormalized tables so queries scan one table instead of joining five. There's no foreign-key graph for an ORM to model. Joins exist and hypequery supports them (.leftJoin(), .innerJoin()), but they're a query decision, not a schema-level relationship an ORM should own.

This is also the honest answer to "why is there no Prisma for ClickHouse": the entity model at the heart of Prisma, TypeORM, and Drizzle's relational layer assumes row-level transactional semantics that ClickHouse intentionally doesn't have. Prisma has no native ClickHouse connector, and the MySQL-wire workaround breaks on ClickHouse-specific syntax — the practical details are in using Prisma with ClickHouse.

Where hypequery sits — and what it deliberately isn't

hypequery is three layers, none of which is an ORM:

Typed query builder (@hypequery/clickhouse) — everything above, ClickHouse-native.

Schema codegen — this is the part people mistake for ORM behavior, so it's worth being precise. An ORM's models define the schema and generate migrations toward it. hypequery goes the other direction: your ClickHouse tables are the source of truth, and the CLI introspects them to generate types.

You get the compile-time safety ORMs are loved for, without hypequery claiming ownership of DDL. Migrations, table engines, sort keys, TTLs — those stay in your hands (or your migration tool's), because getting ORDER BY and engine choices right in ClickHouse is a performance decision no codegen tool should make for you.

Semantic layer (@hypequery/datasets) — the layer ORMs don't have at all. Instead of entities, you define datasets with dimensions and measures, and derive governed metrics from them:

An Order entity models one row's lifecycle. A dataset models how a table aggregates: which columns are safe to group by, which measures mean what, which key isolates tenants. For analytics, the second abstraction is the one that pays rent. More on this in the ClickHouse semantic layer guide.

To be explicit about what's missing, on purpose: hypequery has no migrations engine, no identity map, no change tracking, no save(). If a library advertised those features for ClickHouse, it would be promising a workflow the database doesn't support.

Side by side

Full ORM (Prisma, TypeORM)Query builder (Kysely, hypequery)
Core abstractionEntities with lifecycleTyped SQL construction
Write modelLoad → mutate → save, transactionalBulk inserts, append-first
Read modelFetch objects by key/relationsAggregate millions of rows
Schema ownershipModels generate migrationsDatabase is source of truth; types generated from it
RelationsDeclared, traversed, lazy/eager loadedExplicit joins per query
Natural databasePostgres, MySQL (OLTP)ClickHouse (OLAP), any SQL for Kysely
ClickHouse supportNone nativehypequery: native

When you actually want an ORM

None of this means ORMs are the wrong tool generally. If you're building the transactional side of an application — users, subscriptions, orders being created and updated — an ORM on Postgres or MySQL is a fine, often excellent choice. The common production shape is both at once: Prisma or Drizzle owning the OLTP database, and a ClickHouse-native layer owning analytics, with events flowing from one to the other. Your Order entity lives in Prisma; your "revenue by region by day for this tenant" query lives in hypequery. Neither tool should try to do the other's job.

The mistake to avoid is dragging the ORM abstraction across the boundary — forcing entity semantics onto a columnar store, or hand-writing untyped SQL strings on the analytics side because "there's no ORM for ClickHouse." The first fights the database; the second throws away type safety you can have for free.

Where to go from here

Related content

Continue with the most relevant next reads