
Clickhouse Js Node Coding
- 4.7k installs
- 510 repo stars
- Updated August 2, 2026
- clickhouse/agent-skills
clickhouse-js-node-coding is an agent skill that teaches idiomatic Node.js coding with the @clickhouse/client package for developers who build analytics APIs, event inserts, and parameterized queries on ClickHouse server
About
clickhouse-js-node-coding is an agent skill from clickhouse/agent-skills for building with the ClickHouse Node.js client `@clickhouse/client` in Node.js, Next.js Node runtime routes, React Server Components, and Server Actions—not Edge or browser runtimes. It maps tasks to 12 reference files covering client configuration, ping health checks, JSONEachRow inserts and selects, async inserts, query parameter binding with `{name: Type}` syntax, sessions, compression, and modern types like Dynamic, Variant, JSON, Time, Time64, and QBit. The skill mandates `query_params` instead of template-literal SQL interpolation, explicit SQL injection warnings, and `await client.close()` in snippets. Use it when wiring analytics endpoints, bulk event ingestion, or parameterized reporting queries in Node backends backed by ClickHouse.
- Uses official ClickHouse JS client patterns
- Authors efficient analytical SQL and aggregations
- Handles batch inserts and async ingestion
- Applies Node service integration best practices
- Addresses timeouts, pooling, and type mapping
Clickhouse Js Node Coding by the numbers
- 4,668 all-time installs (skills.sh)
- +942 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #23 of 911 Databases skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/clickhouse/agent-skills --skill clickhouse-js-node-codingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4.7k |
|---|---|
| repo stars | ★ 510 |
| Last updated | August 2, 2026 |
| Repository | clickhouse/agent-skills ↗ |
How do you query ClickHouse from Node.js safely?
Implement Node.js clients, queries, inserts, and performance patterns against ClickHouse for analytics APIs and event pipelines.
Who is it for?
clickhouse-js-node-coding fits backend engineers shipping Node.js or Next.js Node-runtime analytics services that read and write ClickHouse with the official JS client.
Skip if: Skip clickhouse-js-node-coding for @clickhouse/client-web browser usage, Edge runtime workers, or ClickHouse troubleshooting hangs and TLS issues covered elsewhere.
When should I use this skill?
Trigger clickhouse-js-node-coding when configuring createClient, inserting JSONEachRow batches, binding query_params, or parsing ClickHouse results in Node.js.
What you get
Node.js client setup code, parameterized insert and select queries, parsed JSONEachRow results, and reference-backed patterns for sessions or async inserts.
- Client configuration snippets
- Parameterized query and insert code
By the numbers
- 12-task reference index rows in the skill Task Index table
- Documents client features from version 1.0.0 through 1.15.0 and server types from 24.1 onward
Files
ClickHouse Node.js Client — Coding
Reference: https://clickhouse.com/docs/integrations/javascript
⚠️ Node.js runtime only. This skill covers the @clickhouse/clientpackage running in a Node.js runtime exclusively — including **Next.js
Node runtime** API routes, React Server Components, Server Actions, and
standard Node.js processes. Do not apply this skill to browser client
components, Web Workers, Next.js Edge runtime, Cloudflare Workers, or
any usage of @clickhouse/client-web. For browser/edge environments, thecorrect package is @clickhouse/client-web.---
How to Use This Skill
1. Match the user's intent to a row in the Task Index below and read the corresponding reference file before writing code. After reading it, scan any Answer checklist in that reference and make sure the final answer covers each relevant item; those checklists capture details users usually need but are easy to omit in short answers. 2. Always import from `@clickhouse/client` (never @clickhouse/client-web) and create a client with createClient({ url }) or rely on supported defaults when appropriate. Close it with await client.close() preferably when it's no longer needed or during graceful shutdown for global resources. 3. Prefer `JSONEachRow` for typical row inserts/selects unless the user has already chosen another format or is streaming raw bytes (CSV / TSV / Parquet — see examples/node/performance/). Note on `clickhouse_settings`: settings passed to createClient are defaults for every request; they can be overridden per-call by passing clickhouse_settings directly to insert(), query(), or command(). Always mention this when the user configures settings at the client level. 4. Always use `query_params` for user-supplied values — never template- literal-interpolate them into SQL. See reference/query-parameters.md. When answering a parameter-binding question, your response must explicitly name template-literal interpolation as a "SQL injection risk" — even when the user only asked about syntax and did not raise security. The literal phrase "SQL injection" needs to appear; this is the most common mistake from PostgreSQL/MySQL users and the security framing is part of the correct answer, not an optional aside. 5. Pick the right method for the job:
client.insert()— write rows.client.query()+resultSet.json()/.text()/.stream()— read
rows that return data.
client.command()— DDL and other statements that don't return rows
(CREATE, DROP, TRUNCATE, ALTER, SET in a session, etc.).
client.exec()— when you need the raw response stream of an arbitrary
statement (rare in coding scenarios).
client.ping()— health check; returns{ success, error? }, never
throws on connection failure. 6. Note version constraints when relevant. Examples:
pathnameconfig option: client>= 1.0.0.BigIntvalues inquery_params: client>= 1.15.0.TupleParamand JSMapinquery_params: client>= 1.9.0.- Configurable
json.parse/json.stringify: client>= 1.14.0. Time/Time64data types: ClickHouse server>= 25.6.Dynamic/Variant/ newJSONtypes: ClickHouse server>= 24.1/
24.5 / 24.8 (no longer experimental since 25.3).
---
Task Index
Identify the user's task and read the matching reference file.
| Task | Triggers / symptoms | Reference file |
|---|---|---|
| Configure / connect the client | Building a createClient call, URL parameters, clickhouse_settings, default format, custom HTTP headers | reference/client-configuration.md |
| Ping the server | Health checks, readiness probes, "is ClickHouse up?" | reference/ping.md |
| Choose an insert format | "Which format should I use to insert?", JSON vs raw, JSONEachRow vs JSON vs JSONObjectEachRow | reference/insert-formats.md |
| Insert into a subset of columns / different database | insert({ columns }), excluding columns, ephemeral columns, cross-DB inserts | reference/insert-columns.md |
| Insert values, expressions, dates, decimals | INSERT … VALUES with SQL functions, Date/DateTime from JS, Decimal precision, INSERT … SELECT | reference/insert-values.md |
| Async inserts (server-side batching) | async_insert=1, fire-and-forget vs wait-for-ack | reference/async-insert.md |
| Select and parse results | JSONEachRow reads, JSON with metadata, picking a select format | reference/select-formats.md |
| Parameterize queries | Binding values, special characters / escaping, "SQL injection?", {name: Type} syntax | reference/query-parameters.md |
| Sessions & temporary tables | session_id, CREATE TEMPORARY TABLE, per-session SET commands | reference/sessions.md |
| Modern data types | Dynamic, Variant, JSON (object), Time, Time64 | reference/data-types.md |
| Custom JSON parse/stringify | Plug in JSONBig / safe-stable-stringify / a BigInt-aware serializer | reference/custom-json.md |
---
Conventions used in answers
- Always show
import { createClient } from '@clickhouse/client'(Node, never
Web).
- Always
await client.close()at the end of self-contained snippets; in
long-running services, close on graceful shutdown.
- For inserts, prefer
format: 'JSONEachRow'andvalues: [...]unless the
user's scenario requires otherwise.
- For selects, prefer
await (await client.query({...})).json<RowType>()for
small / medium result sets; for bigger results suggest streaming.
- When showing parameter binding, use ClickHouse's native
{name: Type}
syntax — never $1, ?, or :name.
- For DDL inside a cluster or behind a load balancer, set
clickhouse_settings: { wait_end_of_query: 1 } on the command() call so the server only acknowledges after the change is applied. See https://clickhouse.com/docs/en/interfaces/http/#response-buffering.
---
Out of scope
This skill covers day-to-day coding against @clickhouse/client (Node). The following topics are intentionally not covered here:
- **Errors, hangs, type mismatches, proxy pathname surprises, log silence,
socket hang-ups, ECONNRESET** → use the clickhouse-js-node-troubleshooting skill.
- **Streaming, Parquet, file streams, server-side bulk moves, progress
streaming, async-insert throughput tuning** — see `examples/node/performance/`.
- TLS, RBAC / read-only users, deeper SQL-injection guidance — see
- **
CREATE TABLEpatterns, deployment-shaped connection strings,
replication / sharding choices** — see `examples/node/schema-and-deployments/`.
- Browser, Web Worker, Next.js Edge, Cloudflare Workers — use
@clickhouse/client-web and see `examples/web/`.
---
Still Stuck?
- `examples/node/coding/` — the runnable corpus this skill is built on.
- ClickHouse JS client docs
- ClickHouse supported formats
- ClickHouse data types
Async Inserts
Applies to: all client versions; the relevant settings are server-side.
See https://clickhouse.com/docs/en/optimize/asynchronous-inserts.
When to use async inserts: when many small inserts arrive concurrently
(e.g., one per HTTP request) and you don't want to maintain a client-side
batching layer. ClickHouse will batch them server-side. This is also the
recommended ingestion pattern for ClickHouse Cloud.
When _not_ to use async inserts: when you already build large batches
client-side (e.g., from a stream). Plain inserts are simpler and lower
latency.
Setup
Enable on the client level or per-request via clickhouse_settings:
import { createClient, ClickHouseError } from '@clickhouse/client'
const client = createClient({
url: process.env.CLICKHOUSE_URL,
password: process.env.CLICKHOUSE_PASSWORD,
max_open_connections: 10,
clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1, // wait for ack from server
async_insert_max_data_size: '1000000',
async_insert_busy_timeout_ms: 1000,
},
})Concurrent small inserts
Each call still uses the client's normal insert() API — the server merges the batches.
const promises = [...new Array(10)].map(async () => {
const values = [...new Array(1000).keys()].map(() => ({
id: Math.floor(Math.random() * 100_000) + 1,
data: Math.random().toString(36).slice(2),
}))
await client
.insert({ table: 'async_insert_example', values, format: 'JSONEachRow' })
.catch((err) => {
if (err instanceof ClickHouseError) {
// err.code matches a row in system.errors
console.error(`ClickHouse error ${err.code}:`, err)
return
}
console.error('Insert failed:', err)
})
})
await Promise.all(promises)wait_for_async_insert — fire-and-forget vs ack
wait_for_async_insert | Promise resolves when… | Trade-off |
|---|---|---|
1 (default) | Server has flushed the batch to the table | Slower per call; insert errors surface to the client |
0 | Server accepted the row into its in-memory buffer | Faster; flush errors won't surface — only validation/parsing errors |
With wait_for_async_insert: 1, expect each insert call to take roughly async_insert_busy_timeout_ms to resolve when traffic is light, because the server waits for more rows or for the timer to fire before flushing.
Combining DDL with async inserts
When creating tables in scripts that immediately insert, ack the DDL with wait_end_of_query: 1 so the table is ready before the first insert:
await client.command({
query: `
CREATE OR REPLACE TABLE async_insert_example (id Int32, data String)
ENGINE MergeTree ORDER BY id
`,
clickhouse_settings: { wait_end_of_query: 1 },
})Even better is to create a specialized client for inserts with the appropriate async settings and a separate client for DDL and other queries.
Common pitfalls
- Setting `async_insert` per call but expecting client-side batching.
The client still issues each insert() as a separate HTTP request — the batching happens on the server.
- **Confusing
wait_for_async_insert(async-insert ack) with
wait_end_of_query (DDL ack).** They are unrelated.
- **Treating a resolved insert under
wait_for_async_insert: 0as
durably written.** It only means the server accepted the bytes; flush failures will not surface to the client.
- Not handling `ClickHouseError`. It exposes
err.code, which maps to
rows in the system.errors table — use it to decide whether to retry.
See also
- For raw throughput tuning of large async-insert workloads,
Client Configuration
Applies to: all versions, with these notable additions:
>
-pathnameconfig option: client>= 1.0.0.
-clickhouse_setting_*/ch_*URL parameters: client>= 1.0.0.
-keep_alive.idle_socket_ttl(Node-only): client>= 1.0.0.
Answer checklist
When answering configuration questions, include the relevant points:
- Show
createClientfrom@clickhouse/clientwith explicit fields when the
user is writing code; this is easier to read and review than encoding everything into a URL string.
- When mentioning the URL form for environment variables / DSNs: show a Bash
export with the literal URL value, and createClient({ url: process.env.CLICKHOUSE_URL }) in the Node code. Never construct a URL in application code — no string concatenation, no template literals, no query-string builders.
- If URL parameters and object fields both set the same option, URL parameters
override the rest of the configuration object.
- If
clickhouse_settingsappear oncreateClient, explain that they are
defaults for every request and can be overridden on individual query(), insert(), command(), or exec() calls.
- Remind long-running services to close the client during graceful shutdown.
- The
applicationfield sets the name that appears insystem.query_log.
Do not mention any specific HTTP header name — the client handles header mapping internally and the header names are an implementation detail.
Minimal client
import { createClient } from '@clickhouse/client'
const client = createClient({
url: process.env.CLICKHOUSE_URL, // defaults to 'http://localhost:8123'
username: process.env.CLICKHOUSE_USER, // defaults to 'default'
password: process.env.CLICKHOUSE_PASSWORD, // defaults to ''
database: 'analytics', // defaults to 'default'
})
// ... your queries ...
await client.close()url accepts a string or a URL object. The accepted string format is:
http[s]://[username:password@]hostname:port[/database][?param1=value1¶m2=value2]Configuration via URL
Prefer explicit object fields in application code. Use the URL form when the application receives one connection string from an environment variable, secret manager, or config file. The URL value belongs in the environment, not in the source code — show it as a shell export and read it in Node:
# In your shell environment / deployment config (e.g. .env, Kubernetes secret):
export CLICKHOUSE_URL='https://bob:secret@my.host:8124/analytics'// In your Node.js code — no URL construction needed:
const client = createClient({ url: process.env.CLICKHOUSE_URL })Per-client vs per-request clickhouse_settings ⭐
Always mention this when discussing `clickhouse_settings`: settings set
on createClient are defaults; any individual call can override them.Settings on createClient apply to every request. Settings on a single operation (query, insert, command, exec) override the client defaults for that call only.
const client = createClient({
clickhouse_settings: {
output_format_json_quote_64bit_integers: 0, // applied to every request
},
})
const rows = await client.query({
query: 'SELECT number FROM system.numbers LIMIT 2 FORMAT JSONEachRow',
clickhouse_settings: {
output_format_json_quote_64bit_integers: 1, // overrides client default for this call
},
})default_format for exec()
client.exec() runs an arbitrary statement and returns a stream. If your query has no trailing FORMAT … clause, set default_format so the server knows what to send back, then wrap the response in a ResultSet:
import { createClient, ResultSet } from '@clickhouse/client'
const client = createClient()
const format = 'JSONCompactEachRowWithNamesAndTypes'
const { stream, query_id } = await client.exec({
query: 'SELECT database, name, engine FROM system.tables LIMIT 5',
clickhouse_settings: { default_format: format },
})
const rs = new ResultSet(stream, format, query_id)
console.log(await rs.json())
await client.close()For ordinary SELECTs prefer client.query({ format }) — default_format is only needed for raw exec().
Common pitfalls
- **Don't put a path in
urland expect it to be the database name when
you're behind a proxy.** Use pathname for the proxy path and database for the DB. (Symptom: "wrong database selected.") See the troubleshooting skill for diagnosis.
- Don't create a client per request.
createClientopens a connection
pool; share one client across requests and close() on shutdown.
- `max_open_connections` must be `>= 1` when set explicitly.
Custom JSON parse / stringify
Requires: client>= 1.14.0(configurablejson.parseand
json.stringify). Earlier versions cannot swap the JSON implementation.Answer checklist
When the user wants UInt64/Int64 values back as BigInt:
- State that configurable
json.parse/json.stringifyrequires
@clickhouse/client >= 1.14.0.
- Show the supported
createClient({ json: { parse, stringify } })option,
usually with json-bigint and useNativeBigInt: true.
- Combine it with
output_format_json_quote_64bit_integers: 0so the server
emits unquoted 64-bit integers that the parser can turn into BigInt.
- Mention that
output_format_json_quote_64bit_integers: 0is the default
since ClickHouse 25.8, but setting it explicitly is useful for older servers or portable examples.
- Warn that casting to JavaScript
Number/parseInt/parseFloatloses
precision above Number.MAX_SAFE_INTEGER.
Why customize?
The default JSON.stringify / JSON.parse:
- Throws on
BigInt. - Calls
Date.prototype.toJSON()(ISO string) — fine forDateTimewith
date_time_input_format: 'best_effort', surprising in some workflows.
- Loses precision for 64-bit integers returned as numbers (a separate
issue — covered in the troubleshooting skill).
A custom { parse, stringify } lets you plug in JSONBig, safe-stable-stringify, your own BigInt-aware serializer, etc.
Recipe: BigInt-safe stringify, custom Date handling
import { createClient } from '@clickhouse/client'
const valueSerializer = (value: unknown): unknown => {
// Serialize Date as a UNIX millis number (instead of toJSON's ISO string)
if (value instanceof Date) {
return value.getTime()
}
// Serialize BigInt as a string so JSON.stringify won't throw
if (typeof value === 'bigint') {
return value.toString()
}
if (Array.isArray(value)) {
return value.map(valueSerializer)
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, valueSerializer(v)]),
)
}
return value
}
const client = createClient({
json: {
parse: JSON.parse, // use default parsing
stringify: (obj: unknown) => JSON.stringify(valueSerializer(obj)),
},
})
await client.command({
query: `
CREATE OR REPLACE TABLE inserts_custom_json_handling
(id UInt64, dt DateTime64(3, 'UTC'))
ENGINE MergeTree
ORDER BY id
`,
})
await client.insert({
table: 'inserts_custom_json_handling',
format: 'JSONEachRow',
values: [
{
id: BigInt('250000000000000200'), // serialized as a string
dt: new Date(), // serialized as ms since epoch
},
],
})
await client.close()The customvalueSerializerruns beforeJSON.stringify, so values
are transformed before the standard hooks (Date.prototype.toJSON,object toJSON() methods, etc.) ever run.Recipe: BigInt-safe parsing for 64-bit integer columns
If you want UInt64/Int64 to come back as BigInts (instead of strings or precision-lossy numbers), plug in a BigInt-aware parser such as `json-bigint`:
import { createClient } from '@clickhouse/client'
import JSONBig from 'json-bigint'
const bigJson = JSONBig({ useNativeBigInt: true })
const client = createClient({
json: {
parse: bigJson.parse,
stringify: bigJson.stringify,
},
clickhouse_settings: {
output_format_json_quote_64bit_integers: 0,
},
})output_format_json_quote_64bit_integers: 0 is the default since ClickHouse 25.8; setting it explicitly is useful for older servers and makes the example self-contained. With it off, the server emits unquoted 64-bit integers that json-bigint parses straight to BigInt. The json option applies to both outgoing JSON bodies and incoming JSON-format responses.
Recipe: Zero-dep BigInt parsing (no npm install)
If adding a dependency is awkward (locked lockfile, restricted environment, or you just don't want to pull in json-bigint), you can plug in a hand-rolled reviver. This uses the context.source argument that JSON.parse revivers gained in Node >= 20.16 / >= 21.7, so the raw numeric literal is available before it's coerced to a JS number:
import { createClient } from '@clickhouse/client'
const parseBigInt = (text: string) =>
JSON.parse(text, function (key, value, context) {
if (key.endsWith('__bigint')) {
return BigInt(context.source)
}
return value
})
const client = createClient({
json: {
parse: parseBigInt,
stringify: JSON.stringify, // use default stringify
},
clickhouse_settings: { output_format_json_quote_64bit_integers: 0 },
})
const rs = await client.query({
query: 'SELECT toUInt64(250000000000000200) AS id__bigint',
})
const { data } = await rs.json()
console.log(data[0].id__bigint) // 250000000000000200
await client.close()Trade-offs versus json-bigint:
- ✓ No new dependency to install.
- ✓ Only promotes known to be 64-bit integers to
BigInt. - ✗ Requires Node
>= 20.16/>= 21.7for the revivercontext.source.
On older Node, prefer json-bigint or upgrade Node.
- ✗ Outgoing
stringifystill uses defaultJSON.stringify, which throws
on BigInt. Pair with the valueSerializer pattern above if your inserts contain BigInt values.
Common pitfalls
- Setting `json.parse` only. That only affects reading JSON responses;
outgoing JSON bodies use json.stringify. If you want consistent custom handling in both directions, generally provide a matching stringify too or a throwing serializer that prevents mismatches.
- Forgetting `bigint` handling in `stringify`. Default
JSON.stringify
throws on BigInt; if your data ever contains one, the insert will fail with TypeError: Do not know how to serialize a BigInt.
- Targeting client `< 1.14.0`. The
jsonoption doesn't exist; you'll
need to convert values manually before calling insert() / query() (or upgrade).
- Casting 64-bit integers to `Number`. JavaScript's
numbertype has
only 53 bits of mantissa — values above Number.MAX_SAFE_INTEGER (2^53 − 1) are silently rounded. Do not try to fix precision loss by calling Number(), parseInt(), or parseFloat() on the value. The correct fix is a BigInt-aware parser (shown above), not a lossy cast.
- Mixing BigInt and number for the same column. If some values are
BigIntand
others are number, your app code needs to handle both types. Otherwise JavaScript will throw a TypeError: Cannot mix BigInt and other types.
Modern Data Types: Dynamic, Variant, JSON, Time, Time64
Applies to (server side):
>
-Variant: ClickHouse>= 24.1.
-Dynamic: ClickHouse>= 24.5.
- NewJSON(object) type: ClickHouse>= 24.8.
- All three are no longer experimental since `25.3`; on older servers,
you must enable the corresponding allow_experimental_*_type setting.-Time/Time64: ClickHouse>= 25.6and require
enable_time_time64_type: 1.Answer checklist
When answering about storing and reading JSON objects:
- Use the new
JSONcolumn type, introduced in ClickHouse>= 24.8. - Say
JSONis no longer experimental since ClickHouse25.3; on older
supported versions, enable allow_experimental_json_type.
- State the version policy in your explanation AND inline in the code.
When you set allow_experimental_json_type (or any allow_experimental_*_type) in code, you must do BOTH of the following: 1. Put an inline comment directly above the setting that names the version where the type was introduced and the version where it became non-experimental. The comment is the durable version provenance — it lives in the user's source file long after the chat reply is gone. 2. Repeat the version policy in your prose reply.
For the JSON column type the inline comment must look like:
clickhouse_settings: {
// JSON type introduced in ClickHouse 24.8, non-experimental since 25.3.
// This setting is required only on 24.8–25.2; harmless on >= 25.3.
allow_experimental_json_type: 1,
}Without the inline comment, a reader on a newer server has no idea the setting is a no-op and a reader on an older server has no idea why it's required.
- Insert real JS objects with
format: 'JSONEachRow'; do not
JSON.stringify() the column value.
- Read with a JSON output format such as
JSONEachRowandresultSet.json();
JSON column values come back as parsed JS objects.
Dynamic, Variant(...), JSON
import { createClient } from '@clickhouse/client'
const client = createClient({
clickhouse_settings: {
// Variant introduced in 24.1, Dynamic in 24.5, JSON in 24.8.
// All three are non-experimental since 25.3; these settings are
// required only on 24.1–25.2 and are harmless on >= 25.3.
allow_experimental_variant_type: 1,
allow_experimental_dynamic_type: 1,
allow_experimental_json_type: 1,
},
})
await client.command({
query: `
CREATE OR REPLACE TABLE chjs_dynamic_variant_json
(
id UInt64,
var Variant(Int64, String),
dynamic Dynamic,
json JSON
)
ENGINE MergeTree
ORDER BY id
`,
})
await client.insert({
table: 'chjs_dynamic_variant_json',
format: 'JSONEachRow',
values: [
{ id: 1, var: 42, dynamic: 'foo', json: { foo: 'x' } },
{ id: 2, var: 'str', dynamic: 144, json: { bar: 10 } },
],
})
const rs = await client.query({
query: `
SELECT *,
variantType(var),
dynamicType(dynamic),
dynamicType(json.foo),
dynamicType(json.bar)
FROM chjs_dynamic_variant_json
`,
format: 'JSONEachRow',
})
console.log(await rs.json())Outputs:
;[
{
id: '1',
var: '42',
dynamic: 'foo',
json: { foo: 'x' },
'variantType(var)': 'Int64',
'dynamicType(dynamic)': 'String',
'dynamicType(json.foo)': 'String',
'dynamicType(json.bar)': 'None',
},
{
id: '2',
var: 'str',
dynamic: '144',
json: { bar: '10' },
'variantType(var)': 'String',
'dynamicType(dynamic)': 'Int64',
'dynamicType(json.foo)': 'None',
'dynamicType(json.bar)': 'Int64',
},
]Notes
- The
JSONcolumn type accepts a real JS object on insert and returns one
on select — no need for JSON.stringify / JSON.parse in your app code.
- A JS number written into a
DynamicorVariantcolumn defaults to
Int64 on the server. In JSON formats, output_format_json_quote_64bit_integers controls how 64-bit integers are returned: 1 returns them as JSON strings, while 0 returns them as JSON numbers (and 0 is the default since CH 25.8). In JS, large 64-bit integers returned as numbers can lose precision, so use quoted output if you need exact integer values in application code.
- Use
variantType(...),dynamicType(...)to introspect what the server
ended up storing.
Time and Time64(p)
Time is signed seconds (-999:59:59 … 999:59:59). Time64(p) adds sub-second precision (p digits, up to 9 for nanoseconds). Both require enable_time_time64_type: 1 on >= 25.6.
const client = createClient({
clickhouse_settings: { enable_time_time64_type: 1 },
})
await client.command({
query: `
CREATE OR REPLACE TABLE chjs_time_time64
(
id UInt64,
t Time,
t64_0 Time64(0),
t64_3 Time64(3),
t64_6 Time64(6),
t64_9 Time64(9),
)
ENGINE MergeTree
ORDER BY id
`,
})
await client.insert({
table: 'chjs_time_time64',
format: 'JSONEachRow',
values: [
{
id: 1,
t: '12:34:56',
t64_0: '12:34:56',
t64_3: '12:34:56.123',
t64_6: '12:34:56.123456',
t64_9: '12:34:56.123456789',
},
{
id: 2,
t: '999:59:59',
t64_0: '999:59:59',
t64_3: '999:59:59.999',
t64_6: '999:59:59.999999',
t64_9: '999:59:59.999999999',
},
{
id: 3,
t: '-999:59:59',
t64_0: '-999:59:59',
t64_3: '-999:59:59.999',
t64_6: '-999:59:59.999999',
t64_9: '-999:59:59.999999999',
},
],
})Notes
- Pass values as strings in the
HH:MM:SS[.fraction]format. Negatives
are supported; the magnitude can exceed 24 hours.
- For
Time64(p)withp > 3, do not use JSDate— it tops out at
millisecond precision and will silently truncate. Store nanosecond values separately and provide on stringify as needed.
Common pitfalls
- *Targeting old ClickHouse servers without the `allow_experimental_`
setting.** On < 25.3, CREATE TABLE will fail without them.
- Expecting `JSON`-column reads to be raw strings. They come back as
parsed objects in JSON formats.
- Inserting `Time64(9)` from JS `Date` and losing precision. Use a
string instead.
- **Reading a
Variant/Dynamicvalue of typeInt64and being surprised
it's a string.** That's the standard 64-bit-integers-in-JSON behavior; see the troubleshooting skill if you need to change it.
- Avoid parsing Variant/Dynamic/JSON columns that mix strings and 64-bit
without checking their returned types first. Otherwise a number stored in a string will come back as a number or vice versa.
Insert into Specific Columns / Other Databases
Applies to: all versions. The columns option (both forms) and thedatabase config field are universally supported.Answer checklist
When explaining partial-column inserts:
- Show
columns: ['col_a', 'col_b']for the allowlist form. - Also mention the inverse
columns: { except: ['col_to_skip'] }form so the
user knows both supported shapes.
- Explain that omitted columns receive their server-side defaults
(DEFAULT, MATERIALIZED, ALIAS, nullable/type defaults) and inserts can still fail or produce surprising zero/empty values if the table definition has no appropriate defaults.
Insert into specific columns
Pass columns: string[] to limit the INSERT to a subset. Omitted columns get their declared default.
await client.insert({
table: 'events',
columns: ['message'], // the rest of the events table columns get their DEFAULTs
format: 'JSONEachRow',
values: [{ message: 'foo' }],
})Insert excluding columns
Use columns: { except: string[] } for the inverse. Useful when most columns should default but you want to name only the few to skip.
await client.insert({
table: 'events',
format: 'JSONEachRow',
values: [{ message: 'bar' }],
columns: { except: ['id'] },
})Tables with EPHEMERAL columns
Ephemeral columns are not stored — they only exist to drive DEFAULT expressions of other columns. To trigger that default logic, the ephemeral column must be in the `columns` list, even though no value will be persisted for it.
await client.command({
query: `
CREATE OR REPLACE TABLE events
(
id UInt64,
message String DEFAULT message_default,
message_default String EPHEMERAL
)
ENGINE MergeTree
ORDER BY id
`,
})
await client.insert({
table: 'events',
format: 'JSONEachRow',
values: [
{ id: '42', message_default: 'foo' },
{ id: '144', message_default: 'bar' },
],
// Including the ephemeral column name triggers the DEFAULT expression
columns: ['id', 'message_default'],
})Insert into a different database
If the client's default database is not the target, qualify the table name with db.table:
const client = createClient({ database: 'system' })
await client.command({ query: 'CREATE DATABASE IF NOT EXISTS analytics' })
await client.insert({
table: 'analytics.events', // fully qualified
format: 'JSONEachRow',
values: [{ id: 42, message: 'foo' }],
})There is no per-call database override on insert() / query() — qualify the identifier, or create a second client with the desired database.
Common pitfalls
- Forgetting the ephemeral column in `columns`. If you list only the
non-ephemeral columns, the DEFAULT expression that depends on the ephemeral value won't fire and you'll get empty/zero defaults instead.
- Hoping `client.insert({ database: '…' })` works. It doesn't — qualify
the table instead.
- Mixing the two `columns` forms. Use either
string[]_or_
{ except: string[] }, not both.
Insert Data Formats
Applies to: all versions. The JSON type column / new JSON family is aClickHouse feature; the JSON _formats_ listed here are universally supported
by the client.
**Raw / binary formats (CSV, TSV, CustomSeparated, Parquet) require a Node
stream as input.** Suggest streaming when the user wants to insert from a file or Readable.Answer checklist
When answering "what format/call should I use for an array of JS objects?":
- Use
client.insert({ table, values, format: 'JSONEachRow' }). - Say the array of plain objects can be passed directly as
valuesfor
ordinary in-memory batches such as a few thousand or tens of thousands of rows.
- Do not steer the user to streaming, Parquet, or file APIs unless their input
is already a stream/file or the task is explicitly about throughput.
- Warn not to wrap
JSONEachRowrows in a{ data: [...] }envelope; that
shape belongs to single-document formats.
- Mention
JSONCompactEachRow*as a denser alternative for larger payloads
when the caller can provide positional arrays or explicit names/types.
Default choice: JSONEachRow with an array of objects
This is the right answer for ~90% of inserts.
import { createClient } from '@clickhouse/client'
const client = createClient()
await client.insert({
table: 'events',
format: 'JSONEachRow',
values: [
{ id: 42, name: 'foo' },
{ id: 43, name: 'bar' },
],
})
await client.close()The shape of values must match the chosen format.
Streamable JSON formats (pass an array)
| Format | values shape |
|---|---|
JSONEachRow | Array<{ col: value, ... }> |
JSONStringsEachRow | Array<{ col: stringifiedValue, ... }> |
JSONCompactEachRow | Array<[v1, v2, ...]> |
JSONCompactStringsEachRow | Array<[stringV1, stringV2, ...]> |
JSONCompactEachRowWithNames | First row = column names, then data rows |
JSONCompactEachRowWithNamesAndTypes | Row 1 = names, row 2 = types, then data |
JSONCompactStringsEachRowWithNames | First row = names, then stringified data rows |
JSONCompactStringsEachRowWithNamesAndTypes | Row 1 = names, row 2 = types, then stringified data |
await client.insert({
table: 'events',
format: 'JSONCompactEachRowWithNamesAndTypes',
values: [
['id', 'name', 'sku'],
['UInt32', 'String', 'Array(UInt32)'],
[11, 'foo', [1, 2, 3]],
[12, 'bar', [4, 5, 6]],
],
})These formats can be streamed — pass a Node stream of rows instead of an array. See `examples/node/performance/` for streaming guidance.
Single-document JSON formats (pass an object)
These cannot be streamed — the entire body is sent in one shot.
| Format | values shape (typed via InputJSON<T> / InputJSONObjectEachRow<T>) |
|---|---|
JSON | { meta: [], data: Array<{ col: value, ... }> } — for TypeScript/client usage, pass meta: [] if metadata is not needed |
JSONCompact | { meta: [{ name, type }, ...], data: Array<[v1, v2, ...]> } |
JSONColumnsWithMetadata | { meta: [...], data: { col1: [v, ...], col2: [v, ...] } } |
JSONObjectEachRow | Record<string, { col: value, ... }> (the record key labels each row but is not stored) |
import type { InputJSON, InputJSONObjectEachRow } from '@clickhouse/client'
const meta: InputJSON['meta'] = [
{ name: 'id', type: 'UInt32' },
{ name: 'name', type: 'String' },
]
await client.insert({
table: 'events',
format: 'JSONCompact',
values: {
meta,
data: [
[19, 'foo'],
[20, 'bar'],
],
},
})
await client.insert({
table: 'events',
format: 'JSONObjectEachRow',
values: {
row_1: { id: 23, name: 'foo' },
row_2: { id: 24, name: 'bar' },
} satisfies InputJSONObjectEachRow<{ id: number; name: string }>,
})Quick chooser
| Use case | Format |
|---|---|
| Insert plain JS objects | JSONEachRow _(default)_ |
| Insert tuples / column-positional rows | JSONCompactEachRow |
| Insert with explicit column ordering / types | JSONCompactEachRow*WithNames… |
| Insert a single document with metadata | JSON, JSONCompact |
| Insert from a CSV / TSV / Parquet file | Raw format + Node stream → examples/node/performance/ |
Common pitfalls
- Wrong shape for the format. The most common cause of insert failures —
e.g., passing Array<{...}> to JSONCompact (which expects { meta, data }).
- Don't wrap a `JSONEachRow` array in a `{ data: [...] }` envelope. That
envelope only belongs to single-document formats (JSON / JSONCompact / JSONColumnsWithMetadata).
- For type guidance (
Decimalstrings,Dateobjects,BigInt), see
insert-values.md and custom-json.md.
- Use runtime type checkers like `zod` or `io-ts` if your app ingests untrusted JSON.
It's easier to debug mismatches between your data and the format's expected shape with a validation library used at the place of ingestion than with ClickHouse errors. This is especially true in the middle of a large insert batch or streaming operation.
Insert Values, SQL Expressions, Dates, Decimals
Applies to: all versions. wait_end_of_query: 1 is a server-sidesetting available on every supported ClickHouse version.
INSERT … SELECT (no values payload)
When the data already lives in ClickHouse, use client.command() with a raw INSERT … SELECT:
await client.command({
query: `
INSERT INTO target
SELECT * FROM source
`,
})Use command() (not insert()) — there is no row payload to send.
INSERT … VALUES with SQL functions
When you need unhex(...), toUUID(...), now(), or any other SQL function around a value, keep the SQL shape static and pass values with ClickHouse {name: Type} parameters. Run it via command() and set wait_end_of_query: 1 for safety in clustered setups.
await client.command({
query: `
INSERT INTO events (id, timestamp, email, name)
VALUES (
unhex({id: String}),
{timestamp: DateTime},
{email: String},
{name: Nullable(String)}
)
`,
query_params: {
id: '00112233445566778899aabbccddeeff',
timestamp: '2026-05-06 12:34:56',
email: 'alice@example.com',
name: 'Alice',
},
clickhouse_settings: { wait_end_of_query: 1 },
})Do not build VALUES rows with string interpolation or manual escaping. If you need to insert many ordinary JS rows, prefer client.insert() with format: 'JSONEachRow'; use this command() pattern when the SQL itself needs functions or expressions around the values.
Inserting JS Date objects
JS Date objects work for DateTime and DateTime64 columns once the server is set to accept ISO-8601 strings. Either set date_time_input_format: 'best_effort' per request, on the client, or session-wide.
await client.insert({
table: 'events',
format: 'JSONEachRow',
values: [{ id: '42', dt: new Date() }],
clickhouse_settings: {
date_time_input_format: 'best_effort', // default on the Cloud
},
})JSDateobjects do not work for theDatetype (date-only) — pass
'YYYY-MM-DD' strings for that.Inserting Decimal* values
IMPORTANT: Make sure that the application code you're working on or the user prompt clearly indicates that floats are not used anywhere for decimal values. The most common scenario is using floats for money amounts in the app while the database uses Decimal for them. In that case, the app code should be changed to use a proper decimal library and serialization strategy (custom serializer or a class using toJSON()) to string instead of JS number.
Decimals must be passed as strings in JSON formats to avoid precision loss in JavaScript:
await client.command({
query: `
CREATE OR REPLACE TABLE prices (
id UInt32,
dec32 Decimal(9, 2),
dec64 Decimal(18, 3),
dec128 Decimal(38, 10),
dec256 Decimal(76, 20)
)
ENGINE MergeTree ORDER BY id
`,
})
await client.insert({
table: 'prices',
format: 'JSONEachRow',
values: [
{
id: 1,
dec32: '1234567.89',
dec64: '123456789123456.789',
dec128: '1234567891234567891234567891.1234567891',
dec256:
'12345678912345678912345678911234567891234567891234567891.12345678911234567891',
},
],
})When reading them back, cast to string in the SELECT to avoid the same precision loss:
const rs = await client.query({
query: `
SELECT toString(dec64) AS decimal64,
toString(dec128) AS decimal128
FROM prices
`,
format: 'JSONEachRow',
})Common pitfalls
- Using `client.insert()` for `INSERT … SELECT`. There's nothing to
upload — use client.command() with the full SQL.
- Forgetting `date_time_input_format: 'best_effort'` when inserting
Date objects (or ISO strings). The default input format does not accept ISO-8601 with the T/Z separators.
- Hand-building `VALUES` with user input. Always parameterize user data;
see reference/query-parameters.md.
- Using floats in the app and expect `Decimal` columns to store them safely. Use a proper decimal library and pass them as strings to avoid precision loss.
Ping the Server
Applies to: all versions. ping() returns a discriminated unionPingResult = { success: true } | { success: false, error: Error } —it does not throw on connection failures.
Answer checklist
When answering "how do I health-check / readiness-probe ClickHouse?":
- Use
await client.ping()(orping({ select: true })) and branch on
result.success directly — do not wrap in try/catch as the only check, and do not substitute query('SELECT 1').
- For a readiness probe / "can it serve traffic", recommend
client.ping({ select: true }) so credentials and the query layer are validated, not just the socket.
- Always contrast the two forms explicitly in your answer, even when
you're recommending one: plain client.ping() hits /ping (TCP/HTTP reachability only — does not validate credentials or query processing); client.ping({ select: true }) issues a lightweight SELECT 1 (validates auth and query path). Name both and say which to use for liveness vs readiness.
- Recommend lowering
request_timeouton the client used for probes so
they fail fast instead of hanging on the default timeout — pick a value comparable to the probe interval (e.g., 1500–2000 ms for a 2-second-interval probe).
Successful ping
import { createClient } from '@clickhouse/client'
const client = createClient({
url: process.env.CLICKHOUSE_URL,
password: process.env.CLICKHOUSE_PASSWORD,
})
const pingResult = await client.ping()
if (pingResult.success) {
console.info('ClickHouse is reachable')
} else {
console.error('Ping failed:', pingResult.error)
}
await client.close()Use ping() to:
- Probe ClickHouse at application startup.
- Wake up a ClickHouse Cloud instance that may be idling (a ping is enough to
bring it out of sleep).
- Implement a
/healthz/ readiness endpoint.
Failure: host unreachable
ping() does not throw — it resolves with { success: false, error: Error }, so you can branch without try/catch:
import type { PingResult } from '@clickhouse/client'
import { createClient } from '@clickhouse/client'
const client = createClient({
url: 'http://localhost:8100', // non-existing host
request_timeout: 50, // keep failure fast
})
const pingResult = await client.ping()
if (hasConnectionRefusedError(pingResult)) {
console.info('Connection refused, as expected')
} else {
console.error('Ping expected ECONNREFUSED, got:', pingResult)
}
await client.close()
function hasConnectionRefusedError(
pingResult: PingResult,
): pingResult is PingResult & { error: { code: 'ECONNREFUSED' } } {
return (
!pingResult.success &&
'code' in pingResult.error &&
pingResult.error.code === 'ECONNREFUSED'
)
}Mapping to an HTTP health endpoint
app.get('/healthz', async (_req, res) => {
const r = await client.ping()
if (r.success) {
res.status(200).json({ ok: true })
} else {
res.status(503).json({ ok: false, error: String(r.error) })
}
})ping() vs ping({ select: true })
The default ping() hits ClickHouse's /ping HTTP endpoint — it verifies network connectivity but does not check credentials or query processing. A server that is reachable but has a bad password (or a broken query pipeline) will still return { success: true } from a plain ping().
Pass { select: true } to run a lightweight SELECT 1 instead:
const r = await client.ping({ select: true })
// success only if the server is reachable AND auth is correct AND it can run queriesclient.ping() | client.ping({ select: true }) | |
|---|---|---|
| Endpoint | /ping (HTTP) | SELECT 1 query |
| Checks auth | No | Yes |
| Checks query processing | No | Yes |
| Overhead | Minimal | Slightly higher |
When to use which:
- Liveness probe (is the process alive?) — plain
ping()is fine. - Readiness probe (can it serve traffic?) — use
ping({ select: true })
so the probe fails if credentials are wrong or the query layer is broken.
- Waking a ClickHouse Cloud idle instance — plain
ping()is enough.
Common pitfalls
- Do not wrap `ping()` in `try/catch` as your only check. It resolves on
failure; the success boolean is the source of truth.
- Lower `request_timeout` if you want pings to fail fast (the example
above uses 50 ms). The default is high enough to be unsuitable for liveness probes.
- Plain `ping()` does not check credentials. If auth is part of what you
want to verify, use ping({ select: true }).
- For ping that times out specifically, see the troubleshooting skill.
- Only ping the ClickHouse server in your app's liveness probe if the app
has to be restarted to recover from a ClickHouse outage. If the app can recover the connection to ClickHouse without a restart, put the ping in a readiness probe instead so the app doesn't get killed unnecessarily.
Query Parameter Binding
Applies to: all versions. NULL parameter binding fixed in 0.0.16.Special-character (tab/newline/quote/backslash) binding >= 0.3.1.TupleParamand JSMapparameters>= 1.9.0. Boolean formatting in
Array/Tuple/Mapparameters fixed in>= 1.13.0.BigIntquery
parameters >= 1.15.0.Answer checklist
When the user passes user-controlled values into SQL:
- Use ClickHouse
{name: Type}placeholders and aquery_paramsobject. - **Your response must explicitly name template-literal / string
interpolation of user input as a SQL injection risk** — even when the user only asked "how do I bind values" and did not mention security. This is non-negotiable: the security framing is part of the right answer, not an optional aside.
- Do not suggest PostgreSQL/MySQL-style
$1,?, or:nameplaceholders. - Pick the placeholder type to match the ClickHouse column type (
String,
Date, DateTime, Nullable(T), etc.).
Syntax: {name: Type}
ClickHouse uses {name: Type} placeholders — not $1, ?, or :name.
await client.query({
query: 'SELECT plus({a: Int32}, {b: Int32})',
format: 'JSONEachRow',
query_params: { a: 10, b: 20 },
})The Type must be a valid ClickHouse type (Int32, String, Date, Array(UInt32), Tuple(Int32, String), Map(K, V), Nullable(T), etc.).
⚠️ Never use template literals for user values
Interpolating user input into the SQL string bypasses server-side escaping and opens the door to SQL injection:
const userId = req.params.id
// ❌ Dangerous — never do this with user-controlled values
await client.query({ query: `SELECT * FROM users WHERE id = ${userId}` })
// ✓ Safe — parameterized
await client.query({
query: 'SELECT * FROM users WHERE id = {id: UInt32}',
query_params: { id: userId },
})This is the most common mistake for users coming from PostgreSQL/MySQL. Call it out explicitly when the user shows template-literal interpolation.
Common types
import { TupleParam } from '@clickhouse/client'
await client.query({
query: `
SELECT
{var_int: Int32} AS var_int,
{var_float: Float32} AS var_float,
{var_str: String} AS var_str,
{var_array: Array(Int32)} AS var_array,
{var_tuple: Tuple(Int32, String)} AS var_tuple,
{var_map: Map(Int, Array(String))} AS var_map,
{var_date: Date} AS var_date,
{var_datetime: DateTime} AS var_datetime,
{var_datetime64_3: DateTime64(3)} AS var_datetime64_3,
{var_datetime64_9: DateTime64(9)} AS var_datetime64_9,
{var_decimal: Decimal(9, 2)} AS var_decimal,
{var_uuid: UUID} AS var_uuid,
{var_ipv4: IPv4} AS var_ipv4,
{var_null: Nullable(String)} AS var_null
`,
format: 'JSONEachRow',
query_params: {
var_int: 10,
var_float: '10.557',
var_str: 'hello',
var_array: [42, 144],
var_tuple: new TupleParam([42, 'foo']), // >= 1.9.0
var_map: new Map([
[42, ['a', 'b']],
[144, ['c', 'd']],
]), // >= 1.9.0
var_date: '2022-01-01',
var_datetime: '2022-01-01 12:34:56', // or a Date
var_datetime64_3: '2022-01-01 12:34:56.789', // or a Date
var_datetime64_9: '2022-01-01 12:34:56.123456789', // string for ns precision
var_decimal: '123.45', // string to avoid precision loss
var_uuid: '01234567-89ab-cdef-0123-456789abcdef',
var_ipv4: '192.168.0.1',
var_null: null, // fixed in 0.0.16
},
})Type-by-type tips
- Decimals — pass as strings to avoid JS number precision loss.
- `DateTime64(>3)` — pass as a string; JS
Dateonly has millisecond
precision and will lose sub-millisecond digits.
- `DateTime64` — strings can also be UNIX timestamps, including
fractional ones (e.g., '1651490755.123456789').
- `BigInt` — supported in
query_paramssince>= 1.15.0. On older
clients, pass as a string.
- `Tuple(...)` — wrap in
new TupleParam([...])(>= 1.9.0); on older
clients, build the literal manually as a string.
- `Map(K, V)` — pass a JS
Map(>= 1.9.0); on older clients, build
it manually.
- `Nullable(T)` — pass
nulldirectly (>= 0.0.16).
Special characters in string parameters (>= 0.3.1)
Tabs, newlines, carriage returns, single quotes, and backslashes are escaped automatically by the client — just pass the JS string as-is:
await client.query({
query: `
SELECT
'foo_\t_bar' = {tab: String} AS has_tab,
'foo_\n_bar' = {newline: String} AS has_newline,
'foo_\\'_bar' = {single_quote: String} AS has_single_quote,
'foo_\\_bar' = {backslash: String} AS has_backslash
`,
format: 'JSONEachRow',
query_params: {
tab: 'foo_\t_bar',
newline: 'foo_\n_bar',
single_quote: "foo_'_bar",
backslash: 'foo_\\_bar',
},
})Common pitfalls
- `$1` / `?` / `:name` placeholders. None work — use
{name: Type}. - Forgetting the type in the placeholder.
{id}is a syntax error;
it must be {id: UInt32}.
- Stringifying tuples/maps manually on `>= 1.9.0`. Use
TupleParam
and Map — both serialize correctly and respect special characters.
- Boolean array/tuple/map elements before `1.13.0`. Boolean formatting
was fixed in 1.13.0 — earlier versions may misformat them.
Select Data Formats
Applies to: all versions. JSONEachRowWithProgress requires client>= 1.7.0; see the in-repo performance examples underexamples/node/performance/.Default choice: JSONEachRow → .json<T>()
Right answer for ~90% of selects when the result fits in memory.
import { createClient } from '@clickhouse/client'
interface Row {
number: string
}
const client = createClient()
const rows = await client.query({
query: 'SELECT number FROM system.numbers LIMIT 5',
format: 'JSONEachRow',
})
const result = await rows.json<Row>() // Row[]
result.forEach((r) => console.log(r))
// { number: '0' }
// { number: '1' }
// ...
await client.close()UInt64/Int64 and other 64-bit integers are returned as strings when output_format_json_quote_64bit_integers=1, to avoid JS precision loss. If that setting is 0, they may be returned as unquoted JSON numbers instead. Note that in ClickHouse >= 25.8, this setting can default to 0; see the troubleshooting skill for ways to control that.
Single-document JSON format with metadata
Use JSON (or JSONCompact) when you need ClickHouse's response envelope (rows + meta + statistics + row count). Type the result with ResponseJSON<T>:
import { createClient, type ResponseJSON } from '@clickhouse/client'
const client = createClient()
const rows = await client.query({
query: 'SELECT number FROM system.numbers LIMIT 2',
format: 'JSON',
})
const result = await rows.json<ResponseJSON<{ number: string }>>()
console.info(result.meta, result.data, result.rows, result.statistics)
await client.close()JSON,JSONCompact,JSONStrings,JSONCompactStrings,
JSONColumnsWithMetadata,JSONObjectEachRoware single-document
formats — they cannot be streamed. Use a *EachRow variant if you wantto stream.
Selecting raw text (CSV / TSV / CustomSeparated)
Use .text() (not .json()) for raw textual formats:
const rs = await client.query({
query: 'SELECT number, number * 2 AS doubled FROM system.numbers LIMIT 3',
format: 'CSVWithNames',
})
console.log(await rs.text())Streaming raw text/Parquet line-by-line belongs in `examples/node/performance/` — in particular, Parquet exports use client.exec() and pipe the raw response stream rather than ResultSet.stream() (see `select_parquet_as_file.ts`).
Format chooser
| Use case | Format |
|---|---|
| Read rows as JS objects | JSONEachRow _(default)_ |
| Read rows as positional tuples (smaller payload) | JSONCompactEachRow |
Need meta / statistics / rows envelope | JSON or JSONCompact + ResponseJSON<T> |
| Read all values as strings (avoid number-precision loss) | JSONStringsEachRow / JSONCompactStringsEachRow |
| Stream very large result | JSONEachRow / JSONCompactEachRow (see `examples/node/performance/`) |
| Export to CSV/TSV/Parquet | CSV*, TabSeparated*, Parquet (see `examples/node/performance/`) |
ResultSet methods
| Method | Returns | Notes |
|---|---|---|
await rs.json<T>() | T[] for *EachRow, single-doc shape otherwise | Buffers the full response |
await rs.text() | string | Buffers the full response — for textual formats only (CSV/TSV/etc.) |
rs.stream() | Node Readable of Row[] chunks | Use for large newline-delimited results (JSONEachRow/JSONCompactEachRow/CSV/TSV); not suitable for binary formats like Parquet — for those, use client.exec() and pipe the raw response stream (see `examples/node/performance/`) |
rs.close() | void (synchronous) | Always call if you obtained stream() and stop reading early |
Common pitfalls
- **Calling
.json()on aJSON(single-doc) result and expecting an
array.** You get a ResponseJSON<T> object; the rows are under .data. Use JSONEachRow if you want a flat array.
- Leaving a `stream()` half-consumed. This is a top cause of
ECONNRESET on the _next_ request — fully iterate the stream or call resultSet.close() (synchronous — no await). (Diagnosis details live in the troubleshooting skill.)
- Reaching for `.json()` on a CSV/TSV result. Use
.text()(or
.stream() for large results).
Sessions and Temporary Tables
Applies to: all versions. session_id is a server-level concept; theclient just forwards it on every request that names it.
Answer checklist
When answering "temp table disappears between calls" / "how do I share a session" / anything involving session_id:
- State plainly that **temporary tables and session-scoped state are tied
to a session_id** — without a stable session_id across calls, every request gets a fresh server-side session and the temp table is gone.
- Set
session_idviacrypto.randomUUID()either oncreateClientor
per-call.
- Warn that **
session_idon a global / module-static client is an
anti-pattern** in any concurrent app (Express, server actions, workers, etc.) — concurrent requests will share the same session and trip "Session is locked by a concurrent client". Recommend a short-lived per-workflow client or per-call session_id instead.
- If
session_idis set on the client, also setmax_open_connections: 1
to serialize calls and avoid the concurrent-session error.
- For ClickHouse Cloud or any load-balanced deployment: **explicitly
recommend replica-aware routing or a single-node hostname** as the primary remedy when sessions are needed. Sessions are pinned to one node; behind an LB, consecutive requests may land on different nodes and the temp table will appear to vanish. "Just collapse the workflow into one handler" or "use a non-temporary table" are valid fallbacks but secondary — name the routing fix first.
When you need a session
Use a session_id whenever multiple calls must share server-side state:
CREATE TEMPORARY TABLE(the table only exists within its session).SET <setting> = <value>to apply for subsequent queries on the same
session.
- Any other server feature scoped per session (e.g., session-scoped
variables in newer ClickHouse versions).
⚠️ session_id and concurrency
ClickHouse rejects concurrent queries within the same session — if two requests arrive at the server at the same time sharing the same session_id, the second one gets an error like "Session is locked by a concurrent client". This has two practical implications:
1. Do not set `session_id` on a global / module-static client that handles concurrent requests (e.g., an Express app's shared client). Every in-flight request would share the same session and collide under load. 2. If you do set `session_id` on a client, restrict its concurrency: set max_open_connections: 1 so at most one request is in flight at a time, turning the pool into a serial queue. This is fine for a dedicated per-workflow client but wrong for a shared application client.
The right pattern for application code: create a short-lived client (or use per-request session_id) scoped to a single logical workflow, not to the entire process.
Per-client session_id
Appropriate when one client handles exactly one sequential workflow (a script, a background job, a single user's session that you've already manually serialized in the code).
import { createClient } from '@clickhouse/client'
import * as crypto from 'node:crypto'
const client = createClient({
session_id: crypto.randomUUID(),
max_open_connections: 1, // safeguard against concurrent-session errors
})
await client.command({
query: 'CREATE TEMPORARY TABLE temporary_example (i Int32)',
})
await client.insert({
table: 'temporary_example',
values: [{ i: 42 }, { i: 144 }],
format: 'JSONEachRow',
})
const rs = await client.query({
query: 'SELECT * FROM temporary_example',
format: 'JSONEachRow',
})
console.info(await rs.json())
await client.close()Session-level SET commands
SET only persists within a session. With session_id defined on the client, every subsequent call inherits the change.
import { createClient } from '@clickhouse/client'
import * as crypto from 'node:crypto'
const client = createClient({
session_id: crypto.randomUUID(),
max_open_connections: 1, // safe-guard against concurrent-session errors
})
await client.command({
query: 'SET output_format_json_quote_64bit_integers = 0',
clickhouse_settings: { wait_end_of_query: 1 }, // ack before next call
})
const rs1 = await client.query({
query: 'SELECT toInt64(42)',
format: 'JSONEachRow',
})
// → 64-bit integers come back as numbers in this query
await client.command({
query: 'SET output_format_json_quote_64bit_integers = 1',
clickhouse_settings: { wait_end_of_query: 1 },
})
const rs2 = await client.query({
query: 'SELECT toInt64(144)',
format: 'JSONEachRow',
})
// → 64-bit integers come back as strings again
await client.close()`wait_end_of_query: 1` matters here. Without it, a SET on oneconnection in the pool may not yet be applied when the next query lands
on the same socket.
Per-request session_id
You can also pass session_id on a single query() / insert() / command() call to override (or set) it for that one request.
⚠️ Sessions and load balancers / ClickHouse Cloud
Sessions are bound to a specific ClickHouse node. If a load balancer in front of ClickHouse routes consecutive requests to different nodes, the temporary table / SET won't be visible — you'll get UNKNOWN_TABLE / surprising results.
Mitigations (in order of preference):
- **For ClickHouse Cloud, use [replica-aware
routing](https://clickhouse.com/docs/manage/replica-aware-routing)** so consecutive requests in the same session land on the same node. This is the right primary fix when you need sessions in a Cloud deployment.
- Talk to a single node directly (e.g., a node-pinned hostname) when
routing isn't an option.
- As a fallback only: avoid sessions for cross-node workflows and persist
intermediate state in a regular (non-temporary) table instead. This trades the session requirement away rather than fixing it — use it only if replica-aware routing / single-node connections aren't available.
Common pitfalls
- **Forgetting
session_idand being surprised that
CREATE TEMPORARY TABLE "disappears."** Without a session, every request may land on a different connection / server context.
- Setting `session_id` on a shared application client. Under concurrent
load, two in-flight requests will share the same session and one will fail with "Session is locked by a concurrent client". Use per-request session_id or a dedicated short-lived client instead.
- Reusing the same `session_id` across unrelated workflows. A second
session-using consumer will trip over your temporary tables and SET values. Generate a fresh UUID per logical session.
- Leaving session state pinned for the lifetime of the process. If
long-lived clients accumulate SET / temp-table state, consider creating a short-lived sub-client with its own session_id for the unit of work.
- Skipping `wait_end_of_query: 1` on `SET` — race conditions between
SET and the next query can show up under load.
Related skills
How it compares
Pick clickhouse-js-node-coding over generic SQL skills when implementing the official Node ClickHouse client rather than HTTP curl or ORM abstractions.
FAQ
Which ClickHouse JavaScript package does clickhouse-js-node-coding cover?
clickhouse-js-node-coding covers `@clickhouse/client` for Node.js runtimes only, including Next.js Node API routes and Server Actions, and directs browser or Edge code to `@clickhouse/client-web` instead.
How should user input be passed to ClickHouse queries?
clickhouse-js-node-coding requires `query_params` with ClickHouse `{name: Type}` syntax and explicitly warns that template-literal SQL interpolation creates SQL injection risk.