ClickHouse is a columnar OLAP database built for real-time analytics over very
large datasets — aggregations, rollups, and dashboards scanning billions of
rows, not row-by-row transactional CRUD. Reach for this dialect when your
workload is "append a lot of events, then run fast aggregate queries over
them," and not "many small transactional reads/writes with strict per-row
consistency."Because ClickHouse is architecturally unlike the OLTP dialects
(Postgres/MySQL/MariaDB/SQLite/MSSQL/Oracle), several assumptions the rest of
this ORM makes about "a SQL database" simply do not hold. ClickHouseDialect
(src/dialects/clickhouse/index.ts) is deliberately explicit about where the
behavior genuinely differs — throwing on unsupported capabilities and warning
where an option is silently ignored — rather than papering over the gaps. Read
Key differences from an OLTP database
before you write any code against it.Overview#
| |
|---|
dialect | 'clickhouse' |
| Driver | @clickhouse/client (promise-native, official) |
| Wire protocol | ClickHouse HTTP interface |
| Default port | 8123 (HTTP), 8443 (HTTPS) |
| Transactions | None (single INSERT is atomic at the block level) |
| Constraints | CHECK only; no enforced PRIMARY KEY/UNIQUE/FOREIGN KEY |
| Primary key | No auto-increment; the MergeTree ORDER BY (sorting key) stands in |
| Status | Experimental |
| Property | Value |
|---|
| Dialect name | clickhouse |
| Extends | nothing — implements Dialect directly |
| Driver | @clickhouse/client |
| Cloud SDK | none |
| Default port | 8123 |
| Local runtime | prorm db up --dialect clickhouse (clickhouse/clickhouse-server:24.8.4.13-alpine); docker-compose.test.yml |
Quick start#
Install#
$ npm install ts-prorm-orm @clickhouse/client
$ pnpm add ts-prorm-orm @clickhouse/client
$ yarn add ts-prorm-orm @clickhouse/client
$ bun add ts-prorm-orm @clickhouse/client
Connect#
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'clickhouse',
host: 'localhost',
database: 'default',
username: 'default',
password: process.env.DB_PASSWORD,
dialectOptions: {
protocol: 'http', // 'https' on 8443
},
});
import { Prorm } from 'ts-prorm-orm';
export const db = new Prorm({
dialect: 'clickhouse',
url: 'clickhouse://default:secret@localhost:8123/default',
dialectOptions: {
protocol: 'http', // 'https' on 8443
},
});
A URL is folded into host, port, database, username and password before the dialect is built; any of those passed alongside it wins.Define a model#
import { DataTypes } from 'ts-prorm-orm';
const User = db.define('User', {
id: { type: DataTypes.INTEGER(), primaryKey: true, allowNull: false },
email: { type: DataTypes.STRING(255), allowNull: false },
name: { type: DataTypes.STRING(100) },
}, { timestamps: false });
await db.sync(); // CREATE TABLE — see Generated SQL below
The key has no autoIncrement: the engine has no sequence or identity column the dialect will emit, so the application supplies id. email is not declared unique: the engine has no enforced UNIQUE constraint. timestamps: false leaves out createdAt/updatedAt: with them, sync() gives each a DATETIME DEFAULT now() column.[!NOTE] Keys are not constraints
primaryKey only feeds the table's ORDER BY (sorting key); ClickHouse does not enforce PRIMARY KEY, UNIQUE or FOREIGN KEY, and the dialect warns when a model declares them.
Query#
import { Op } from 'ts-prorm-orm';
await User.create({ id: 1, email: 'ada@example.com', name: 'Ada' });
const page = await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Generated SQL#
Each block is the statement the model call above it hands the dialect's query(), captured with the driver stubbed out. The model layer inlines the values.Create table#
db.sync() for the model in the Quick start:CREATE TABLE
Insert, update and delete#
await User.create({ id: 1, email: 'ada@example.com', name: 'Ada' });
await User.update({ name: 'Grace' }, { where: { email: 'ada@example.com' } });
await User.destroy({ where: { id: 1 } });
Upsert#
await User.upsert({ id: 1, email: 'ada@example.com', name: 'Ada Lovelace' });
upsert() also reads the row by primary key before and after the write, to return the instance.[!WARNING] A plain INSERT
The engine has no conflict clause, so this upsert inserts and never updates an existing row.
await User.findAll({
where: { name: { [Op.like]: 'A%' } },
order: [['id', 'ASC']],
limit: 20,
offset: 40,
});
Transaction#
[!CAUTION] No transactions
ClickHouse does not support multi-statement transactions (no BEGIN/COMMIT/ROLLBACK spanning multiple statements). Only a single INSERT is atomic (at the block level). Do not rely on startTransaction()/commitTransaction()/rollbackTransaction() with this dialect.
DISTINCT ON#
distinctOn keeps the first row of each group in order — here the newest row per name:const latest = db.getDialectInstance()!.buildSelectQuery({
tableName: 'users',
distinctOn: ['name'],
order: [['name', 'ASC'], ['id', 'DESC']],
});
await db.query(latest.sql, { replacements: latest.values });
Type mapping for the portable descriptors: STRING → String, auto-increment INTEGER → Int32, BOOLEAN → Bool, DATE → DateTime, UUID → UUID, JSON → String. The type comparison lines the major engines up side by side.Connection#
Connect over the HTTP interface. dialect: 'clickhouse' selects this dialect;
the driver builds a ${protocol}://${host}:${port} URL from your options.import { Prorm } from 'ts-prorm-orm';
const prorm = new Prorm({
dialect: 'clickhouse',
host: 'localhost',
port: 8123, // ClickHouse's default HTTP port
database: 'default',
username: 'default',
password: '',
dialectOptions: {
protocol: 'http', // 'http' | 'https' (use 8443 for https)
},
});
await prorm.authenticate(); // pings the server up front, like the pooled dialects
ClickHouseDialectOptions also accepts:engine — default ENGINE = ... clause used by createTable() when a
call doesn't specify one. Default: 'MergeTree()'.
orderBy — default ORDER BY column(s) used when a table has no
primaryKey column and no per-call orderBy is given.
requestTimeout — request timeout in ms, forwarded to @clickhouse/client.
clickhouseSettings — arbitrary ClickHouse session settings applied to
every request (e.g. { max_execution_time: 60 }).
Under the hood the dialect routes SELECT/SHOW/DESCRIBE/EXPLAIN through the
driver's query() (reading rows as JSONEachRow) and everything else — DDL,
INSERT, and the ALTER TABLE ... UPDATE/DELETE mutations — through
command(), which returns no row data.Key differences from an OLTP database#
None of these are bugs. They are how ClickHouse works, and they will surprise
you if you treat this dialect like a normal RDBMS.CREATE TABLE requires an ENGINE and (for MergeTree) an ORDER BY#
There is no default storage engine. MergeTree-family engines also require an
ORDER BY — the sorting key, which stands in for a traditional primary key.
It controls physical sort/merge order, not uniqueness.createTable()/Model.sync() default the engine to MergeTree() and infer
ORDER BY in this priority order:1.
an explicit orderBy passed on the table options,
2.
any column(s) marked primaryKey,
3.
the dialect-level orderBy default,
4.
the first declared column,
5.
tuple() ("no sorting key") if nothing else is available.
import { DataTypes } from 'ts-prorm-orm';
const AnalyticsEvent = prorm.define('Event', {
user_id: { type: DataTypes.BIGINT, primaryKey: true },
event_type: DataTypes.STRING,
created_at: { type: DataTypes.DATE, primaryKey: true },
});
// -> CREATE TABLE `Event` (...) ENGINE = MergeTree() ORDER BY (`user_id`, `created_at`)
await prorm.sync();
Note the column defaults are inverted from standard SQL: ClickHouse columns are
NOT NULL unless you opt into Nullable(T). This dialect wraps a column in
Nullable(...) automatically unless allowNull: false is set (or the type
is already Array/Nullable/LowCardinality/Map/Tuple).No PRIMARY KEY / UNIQUE / FOREIGN KEY enforcement#
ClickHouse will happily store duplicate "keys." A column's primaryKey flag
only feeds the ORDER BY inference above; unique and references are ignored
(with a console warning). addForeignKey() throws outright. Only CHECK
constraints map to real syntax (and are validated on INSERT only). If you need
uniqueness or referential integrity, enforce it in your application, or model
"latest row per key" with a ReplacingMergeTree engine plus FINAL reads
(see Upserts).No multi-statement transactions#
There is no BEGIN/COMMIT/ROLLBACK spanning more than one statement — only
a single INSERT is atomic (at the block level). startTransaction(),
commitTransaction(), and rollbackTransaction() all throw rather than
silently no-op, so application code can't mistakenly believe a rollback undid
earlier work. Don't wrap ClickHouse work in prorm.transaction(...). Savepoints
throw for the same reason.UPDATE/DELETE are asynchronous background mutations#
buildUpdateQuery()/buildIncrementQuery() emit ALTER TABLE ... UPDATE, and
buildDeleteQuery() emits ALTER TABLE ... DELETE (or TRUNCATE TABLE when
deleting everything). Both return as soon as the mutation is scheduled — the
actual rewrite happens later, in the background, with progress visible only in
system.mutations. Do not expect affected rows to be immediately
gone/changed on the next SELECT; there is a window where reads still see the
pre-mutation data. Consequently, rowCount after a mutation is always 0 — it
reflects "rows this HTTP request returned," not "rows the mutation will touch."Data types#
DataTypes map onto ClickHouse's native types:ORM DataTypes | ClickHouse type |
|---|
STRING, CHAR, TEXT | String |
INTEGER | Int32 (UInt32 if unsigned) |
BIGINT | Int64 (UInt64 if unsigned) |
FLOAT / DOUBLE | Float32 / Float64 |
DECIMAL(p, s) | Decimal(p, s) |
BOOLEAN | Bool |
DATE | DateTime (DateTime64(p) when a precision is given) |
DATEONLY | Date |
ENUM(...) | Enum8 (≤127 values) or Enum16 |
UUID | UUID |
INET / INET({version:6}) | IPv4 / IPv6 |
JSON / JSONB | String (serialized JSON; query with JSON* functions) |
ARRAY(T) | Array(T) |
TIME, BLOB | String (no dedicated type) |
You can always pass a raw ClickHouse type string as the column type (e.g.
'LowCardinality(String)', 'AggregateFunction(sum, UInt64)') when the
abstraction has no dedicated variant.There is no native auto-increment. A column marked autoIncrement warns and
is ignored — use a DEFAULT generateUUIDv4() on a UUID column, or an
application-maintained counter, instead.A gotcha: string-typed aggregates#
The HTTP driver reads results as JSONEachRow, and ClickHouse serializes 64-bit
integers (UInt64/Int64) as JSON strings to avoid precision loss. That
means count(), sum(), and any UInt64/Int64 column come back as strings,
not JS numbers:const [row] = (await prorm.query('SELECT count() AS c FROM `Event`')).rows;
row.c; // "10423" <-- a string, not 10423
Number(row.c); // 10423 <-- coerce explicitly before doing math
Coerce with Number(...)/BigInt(...) on the way out. Values that fit in
Float64/Int32 come back as JS numbers as expected.Creating tables and MergeTree engines#
Beyond the plain MergeTree() default, the dialect can build and validate the
whole MergeTree family via a typed mergeTreeEngine option on the table
options. Each variant's required columns are checked against your schema before
any SQL reaches ClickHouse:// ReplacingMergeTree: later inserts with the same ORDER BY key win on merge,
// keeping the row with the greatest `version`.
await prorm.getQueryInterface().createTable('users', {
id: { type: DataTypes.BIGINT, primaryKey: true },
email: { type: DataTypes.STRING, allowNull: false },
updated_at: { type: DataTypes.DATE, allowNull: false },
}, {
mergeTreeEngine: { type: 'ReplacingMergeTree', version: 'updated_at' },
} as any);
Supported mergeTreeEngine variants:{ type: 'ReplacingMergeTree', version?, isDeleted? } — isDeleted requires version.
{ type: 'SummingMergeTree', columns? } — columns must be numeric.
{ type: 'AggregatingMergeTree' } — requires at least one
AggregateFunction(...)/SimpleAggregateFunction(...) column.
{ type: 'CollapsingMergeTree', sign } — sign must be an Int8 column.
{ type: 'VersionedCollapsingMergeTree', sign, version } — sign Int8,
version numeric.
If a required column is missing or the wrong type, createTable() throws a
descriptive error instead of letting ClickHouse fail opaquely at insert/merge
time. A raw engine: 'GraphiteMergeTree(...)' string is also honored for
engines without a typed helper.Other MergeTree table options (read as loosely-typed extension fields, since the
shared TableOptions doesn't declare them):orderBy — the sorting key (string or string[]).
partitionBy — a raw PARTITION BY expression, e.g. 'toYYYYMM(created_at)'.
Partitions materialize automatically the first time a row with a given
partition value is inserted; you cannot pre-declare an empty one.
ttl — automatic expiry / tiered storage, e.g.
'created_at + INTERVAL 30 DAY' or '... TO VOLUME \'cold\''. Also available
per-column.
projections — inline PROJECTION name (SELECT ...) alternate layouts.
Inserts and streaming bulkCreate#
A single INSERT is the atomic unit in ClickHouse, and bulk loading is where it
shines. bulkInsert() (backing Model.bulkCreate()) does not build one
giant literal INSERT ... VALUES (...), (...) string — that risks blowing
ClickHouse's max query size and holds the whole batch in memory as one string.
Instead it uses the driver's native streaming insert() with JSONEachRow,
which chunks/streams the encoding:await AnalyticsEvent.bulkCreate([
{ user_id: 1n, event_type: 'view', created_at: new Date() },
{ user_id: 1n, event_type: 'click', created_at: new Date() },
// ... hundreds of thousands of rows stream efficiently
]);
Date values are converted to ClickHouse's YYYY-MM-DD HH:MM:SS[.fff] wire
format (not JS ISO-8601) so DateTime/DateTime64 columns accept them.Upserts (ReplacingMergeTree + FINAL)#
ClickHouse has no synchronous UPSERT/ON CONFLICT/MERGE. The idiomatic
pattern is: insert into a ReplacingMergeTree table and let background merges
asynchronously drop older rows sharing the same ORDER BY key. buildUpsertQuery()
reflects this honestly — it only ever emits a plain INSERT (with a console
warning); conflictFields/updateOnDuplicate are accepted but have no effect.The critical gotcha: those merges are asynchronous and eventual — they can
run minutes later, or effectively never on a lightly-written table. Until then,
duplicate rows for the same key coexist, and a plain SELECT can silently
return duplicates or a stale row, with no error. Pass final: true to
buildSelectQuery() to force merge-time dedup at query time:// WRONG — may return the old row, the new row, or BOTH:
dialect.buildSelectQuery({ tableName: 'users' });
// SELECT * FROM `users`
// CORRECT — force ReplacingMergeTree dedup at read time:
dialect.buildSelectQuery({ tableName: 'users', final: true } as any);
// SELECT * FROM `users` FINAL
FINAL is opt-in on purpose: this dialect doesn't track per-table engine
metadata, so it can't know a table is a ReplacingMergeTree, and FINAL forces a
slower merge-on-read that doesn't scale well on tables with many parts. Ask for
it explicitly on every read that needs deduplicated/latest data. For very large
tables where FINAL is too slow, hand-write an argMax(...)/GROUP BY dedup
query instead — this dialect won't build one for you.Analytics helpers#
ClickHouse's approximate aggregate functions are surfaced as typed helpers that
return a Literal (raw-SQL escape hatch) you drop straight into attributes:const rows = await User.findAll({
attributes: [
dialect.uniqCombined('user_id', 'unique_users'), // approx distinct count
dialect.quantile(0.99, 'latency_ms', 'p99'), // approx p99
],
group: ['day'],
} as any);
Available: uniq, uniqCombined([precision]), uniqHLL12, uniqExact,
quantile(level), quantiles(levels[]), quantileExact, quantileTDigest.
Quantile levels must be in [0, 1].buildSelectQuery() also supports ARRAY JOIN/LEFT ARRAY JOIN (to unnest
Array columns into rows) via arrayJoin/leftArrayJoin options, and
SELECT * EXCEPT (...) via attributes.exclude.Materialized views#
createMaterializedView(name, query, options) emits ClickHouse's real
insert-triggered CREATE MATERIALIZED VIEW ... TO <target> AS SELECT .... Every
time a row lands in the source table(s) in query's FROM, ClickHouse runs the
query against just the new block and appends the result to the target. It is
not recomputed at read time, and does not backfill existing rows unless
you pass populate: true (which races with concurrent inserts, so it's opt-in).
Target either an existing table ({ to: 'rollup' }) or an implicit hidden one
({ engine, orderBy } without to).Known limitations#
No transactions/savepoints — the methods throw (see above).
No enforced PK/UNIQUE/FK constraints — only CHECK; addForeignKey() throws.
No auto-increment — use generateUUIDv4() or an app counter.
No stored procedures — no procedural language; use materialized views or
CREATE FUNCTION UDFs. createStoredProcedure() throws.
No Foreign Data Wrappers — a Postgres concept; use ClickHouse table
functions (mysql(), postgresql(), s3()) directly. FDW methods throw.
No extensions — a Postgres concept; the methods throw.
Indexes are data-skipping indexes (minmax/set/bloom_filter), not
B-Trees — they let scans skip granules, not guarantee point-lookup speed.
No per-column CODEC(...), no Distributed/ON CLUSTER, no dictionaries,
no Kafka/S3 table engines, no SAMPLE clause — out of scope for this
single-node-oriented dialect.
Query parameters are inlined as escaped literals — the HTTP client sends
one opaque SQL string per request, so replacements/bindings are substituted
as escaped literals (the same as SQLite/PostgreSQL do), not native binds.
See also#
clickhouse — the source-level walkthrough this guide
is grounded in.Capabilities#
Computed from the code — capabilitiesOf() (src/transaction-support.ts),
whereSupportOf() (src/query-builders/expression-render.ts),
sqlObjectCapabilitiesOf() (src/sql-objects/capabilities.ts) and the
builders — by the scratch test described in the
capability matrix.| Capability | clickhouse |
|---|
| Transactions atomic | yes |
| Savepoints | no — a nested transaction() throws |
| Isolation | up to SERIALIZABLE |
| Transaction modes | read-only no; deferrable no; constraint deferral no |
| RETURNING | not emitted; returning is accepted and ignored |
| Upsert | a plain INSERT: the engine has no conflict clause (see the page) |
| JSON column | String (JSONB → String) |
| Vector column | Array(Float32) |
| Array column | Array(Int32) |
| WHERE builder | 38 operator keys; $and/$or/$not; $exists: yes; $in subquery: yes; expression operands: yes |
| SQL objects | family clickhouse: triggers no; procedures no; functions expressionOnly; materialized views native; sequences no; enums inline; RLS no |
| Streaming | queryStream() present; LISTEN absent; createExtension() present |
Testing#
tests/dialects/clickhouse.test.ts exercises the dialect with a mocked driver, so nothing touches a server. A live instance: prorm db up --dialect clickhouse (see Local databases).Querying — how a FindOptions becomes SQL Modified at 2026-10-04 00:40:16