Skip to content
Gauge

ax-check.com

ClickHouse

clickhouse.com

ax-check.com

Checked 8 November 2026

A

100/100

Agents need your login

Agents can install, query, and price ClickHouse with zero guesswork.

All 23 checklist items passed: docs, CLI, API, MCP, and skills are all machine-readable, and pricing is published as exact USD rates with clear units.

1 fix to start with

Concrete changes, backed by what we found.

Onboarding

Give agents a clear login handoff and a way to resume setup.

Agents cannot progress through onboarding without human help. Provide an agent-accessible path through authentication and setup.

Recorded coding sessions

Same task, 3 models, no clickhouse.com account. Recorded 8 November 2026 on Pi.

3 recorded · 5m 34s

5m 34s · recorded

Session findings

Select a finding to see it in context.

piv0.84.2
modelQwen 3.8 Maxcwd/sandbox/repo
Help me build a simple example using ClickHouse. Tell me how pricing works, and briefly tell me whether this product will be easy for you to manage. Let me know if you get blocked. If this product has no developer workflow you can act on, say so plainly and stop. Stay light: use the hosted product through its SDK or API. Do not start local service stacks or wait for long-running commands; if the quickstart requires either, say so plainly and stop.
Thinking…

I need to investigate this further. Please let me confirm the details.

ls -la /sandbox/repo && find /sandbox/repo -maxdepth 2 -type f | head -50
total 16
total 16
drwxr-xr-x 3 root root 4096 Sep 18 08:00 .
drwxr-xr-x 7 root root 4096 Nov  8 02:07 ..
drwxr-xr-x 8 root root 4096 Sep 24 20:05 .git
-rw-r--r-- 1 root root  408 Sep 18 08:00 .gitignore
/sandbox/repo/.gitignore
/sandbox/repo/.git/COMMIT_EDITMSG
/sandbox/repo/.git/config
/sandbox/repo/.git/index
/sandbox/repo/.git/HEAD
/sandbox/repo/.git/description
Thinking…

The repository is empty except for .gitignore. In other words, this is a from-scratch task. Let's check the available tools (is the clickhouse client installed? What about Python? What about pip?) and whether credentials are available.

cat /sandbox/repo/.gitignore; echo "---GIT---"; cd /sandbox/repo && git log --oneline -5 2>&1 | head
# Keep generated and vendored files out of source diffs.
# Keep generated and vendored files out of source diffs.

# Logs / OS cruft
*.log
.DS_Store

# JavaScript / TypeScript
node_modules/
.next/
dist/
build/
coverage/

# Python
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
*.egg-info/

# Rust
target/

# Go / PHP / Ruby vendored deps
vendor/
.bundle/

# Java / Kotlin / Scala
.gradle/

# .NET
bin/
obj/

# Elixir
_build/
deps/

# Dart / Flutter
.dart_tool/
---GIT---
9ebf5fb Initial workspace
which clickhouse clickhouse-client python3 pip3 node npm curl 2>&1; echo "---ENV---"; env | grep -iE 'click|ch_|CLICKHOUSE' | sed 's/=.*/=<set>/'; echo "(end env)"; python3 -V
/usr/local/bin/python3
/usr/local/bin/python3
/usr/local/bin/pip3
/usr/local/bin/node
/usr/local/bin/npm
/usr/bin/curl
---ENV---
(end env)
Python 3.12.3
Thinking…

ClickHouse credentials and client aren't installed. So I need to use the hosted product via SDK/API. ClickHouse Cloud requires credentials (URL, user, password). No credentials in the environment variables. Let me check if there's some kind of credentials file.

The task says "use the hosted product via SDK or API." ClickHouse Cloud is a hosted product, but it requires credentials. There's also the public ClickHouse playground: https://play.clickhouse.com/?user=play, which is a free hosted ClickHouse instance accessible via HTTPS API without authentication. This is the legitimate approach — no local stack needed, no long-running commands needed.

Let me test connectivity to play.clickhouse.com.

Also check the network in general (there might be no internet).

Let me try: curl 'https://play.clickhouse.com/?user=play' --data 'SELECT 1'

Also check whether the clickhouse_connect Python package is pip-installable (is there internet to PyPI?).

Let me run both in parallel.

cd /tmp && timeout 25 curl -sS -m 20 'https://play.clickhouse.com/?user=play' --data 'SELECT version()' ; echo "EXIT=$?"
26.10.3.7
26.10.3.7
EXIT=0
cd /tmp && timeout 60 pip3 install --quiet clickhouse-connect 2>&1 | tail -5; echo "EXIT=$?"; python3 -c "import clickhouse_connect; print('clickhouse_connect', clickhouse_connect.__version__)" 2>&1 | tail -3
[notice] A new release of pip is available: 26.2.1 -> 26.3

[notice] A new release of pip is available: 26.2.1 -> 26.3
[notice] To update, run: /opt/freestyle/python/bin/python3 -m pip install --upgrade pip
EXIT=0
clickhouse_connect 1.12.0
Thinking…

The network is working. play.clickhouse.com is reachable and running ClickHouse 26.10.3.7. clickhouse-connect 1.12.0 is installed.

Alright, let me build a simple example: use clickhouse-connect (the official Python SDK) to connect to the hosted ClickHouse playground, create a table, insert data, and query it.

Note: the play.clickhouse.com playground is read-write for the play user, but databases might be shared/ephemeral. Let me test whether CREATE DATABASE / CREATE TABLE works.

First, let me confirm with a simple check whether I can create a table on play.

Actually, let's be mindful of time. Let me just try it.

Plan:

  1. Create a project directory structure: /sandbox/repo/clickhouse-demo/ with example.py, requirements.txt, README.md
  2. Example contents: connect via clickhouse-connect, create a table (events), insert rows, run analytical queries (aggregations), display results.
  3. Also show an alternative using the raw HTTP API (curl) — lightweight, no dependencies.

Let me test whether writes work on the playground.

cd /tmp && timeout 30 curl -sS -m 25 'https://play.clickhouse.com/?user=play' --data "CREATE TABLE IF NOT EXISTS default._probe_pi (id UInt32, ts DateTime) ENGINE = MergeTree ORDER BY id" ; echo "CREATE_EXIT=$?"; timeout 30 curl -sS -m 25 'https://play.clickhouse.com/?user=play' --data "INSERT INTO default._probe_pi VALUES (1, now())"; echo "INS_EXIT=$?"; timeout 30 curl -sS -m 25 'https://play.clickhouse.com/?user=play' --data "SELECT count() FROM default._probe_pi"; echo "SEL_EXIT=$?"
Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant CREATE TABLE ON default._probe_pi. (ACCESS_DENIED
Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant CREATE TABLE ON default._probe_pi. (ACCESS_DENIED) (version 26.10.3.7 (official build))
CREATE_EXIT=0
Code: 60. DB::Exception: Table default._probe_pi does not exist. (UNKNOWN_TABLE) (version 26.10.3.7 (official build))
INS_EXIT=0
Code: 60. DB::Exception: Unknown table expression identifier 'default._probe_pi'. In scope SELECT count() FROM default._probe_pi. (UNKNOWN_TABLE) (version 26.10.3.7 (official build))
SEL_EXIT=0
Thinking…

The public playground is read-only. So we can't create tables there. But read-only is still usable as a demo of a hosted ClickHouse API: querying the pre-installed datasets (the playground has datasets, hits, visits — actually those are on play.clickhouse.com's database datasets... let me confirm). Let me check which databases/tables exist.

For a "simple example" that includes a full write/read cycle, we need credentials for a ClickHouse Cloud instance that we don't have. Options:

  • Read-only demo against the hosted playground (the real hosted ClickHouse, actual HTTP API, actual SDK) — fully doable right now.
  • Write path: make the example work with ClickHouse Cloud too if credentials are provided via env vars, and demo the write path against... hmm.

Can we demo the write path against the hosted one without credentials? Options: ClickHouse Cloud's free tier requires account signup (not doable). Other hosted ClickHouse without credentials? Maybe not.

Alternative: there might be a credentials file somewhere in the sandbox. Let me check ~/.clickhouse, /etc, ~/.config.

Let me check the databases available on play, and also look for credentials files.

cd /tmp && for q in "SHOW DATABASES" "SELECT database, name, total_rows FROM system.tables WHERE database NOT IN ('system','INFORMATION_SCHEMA','information_schema') ORDER BY database,name LIMIT 40"; do echo "== $q"; timeout 25 curl -sS -m 20 'https://play.clickhouse.com/?user=play' --data "$q"; done
== SHOW DATABASES
== SHOW DATABASES
blogs
clickbench
coverage_ci
default
fts_benchmarks_ci
git_clickhouse
keeper_stress_tests
mgbench
system
== SELECT database, name, total_rows FROM system.tables WHERE database NOT IN ('system','INFORMATION_SCHEMA','information_schema') ORDER BY database,name LIMIT 40
blogs	countries	255
blogs	country_codes	279
blogs	forex	11581517103
blogs	forex_2020s	4023159850
blogs	forex_usd	26751083
blogs	noaa	1076882449
blogs	noaa_v2	1076882449
blogs	stop_words	140
blogs	uk_codes	257
clickbench	results	43349
coverage_ci	coverage_data	\N
default	actors	33679
default	benchmark_results	527476
default	benchmark_runs	4251
default	cell_towers	43276158
default	checks	\N
default	checks_coverage_indirect_calls	\N
default	checks_coverage_inverted	\N
default	checks_coverage_inverted2	\N
default	checks_coverage_lines	\N
default	checks_investigated	\N
default	cisco_umbrella	2565000000
default	covid	8730363
default	dish	428146
default	dns	2191280437
default	dns2	2450733093
default	github_events	3173399592
default	hackernews	37171166
default	hackernews_changes_items	96407326
default	hackernews_changes_profiles	30782346
default	hackernews_history	50618636
default	hackernews_top	10869689
default	hits	99997497
default	hits2	99997497
default	hn_styles	798501
default	lineorder	59986052
default	loc_stats	10458728
default	menu	17547
default	menu_item	1334792
default	menu_item_denorm	1329175
ls -la ~ 2>/dev/null; ls -la ~/.config 2>/dev/null; find / -maxdepth 4 \( -iname '*clickhouse*' \) -not -path '*/node_modules/*' -not -path '/proc/*' 2>/dev/null | head -20; echo "(end)"
total 28
total 28
drwxr-xr-x  7 root root 4096 Nov  8 02:07 .
drwxr-xr-x 22 root root 4096 Sep 18 07:59 ..
drwxr-xr-x  4 root root 4096 Nov  8 02:07 .cache
drwxr-xr-x  3 root root 4096 Nov  8 02:07 .gauge
drwxr-xr-x  3 root root 4096 Nov  8 02:07 .pi
drwxrwxrwt  2 root root 4096 Sep 18 08:00 .tmp
drwxr-xr-x  3 root root 4096 Sep 18 08:00 repo
(end)
Thinking…

ClickHouse credentials aren't available. Therefore the write path against an actual hosted ClickHouse can't be demoed. But a full read/analytics demo using the official Python SDK (clickhouse-connect) against hosted ClickHouse (play.clickhouse.com — the official ClickHouse-hosted playground with real datasets) is possible.

What to build:

  • clickhouse-demo/example.py: SDK example against hosted ClickHouse. Supports env var overrides for connecting to your own ClickHouse Cloud instance (CLICKHOUSE_HOST/USER/PASSWORD) with a full write path (create table, insert, query), and defaults to read-only mode on the public playground.
  • clickhouse-demo/http_api_demo.sh (or .py): raw HTTP API with curl — no dependencies.
  • requirements.txt, README.md.

Design details: the script should be runnable without long-running commands. Keep queries small and fast. Use LIMIT and small aggregations.

Picking a dataset: default.hackernews (37M rows) — schema? Check the columns for hackernews and cell_towers. Use queries that finish fast. Also default.github_events is huge (3B rows). Let's inspect the hackernews schema and run a small aggregation to confirm latency.

Also confirm whether client.query on clickhouse_connect works against play with https and user=play (no password).

Let's write the file and test it.

First, inspect the schema.

cd /tmp && timeout 25 curl -sS -m 20 'https://play.clickhouse.com/?user=play' --data "SELECT name, type FROM system.columns WHERE database='default' AND table='hackernews' FORMAT TSVWithNames"; echo "---- cell_towers"; timeout 25 curl -sS -m 20 'https://play.clickhouse.com/?user=play' --data "SELECT name, type FROM system.columns WHERE database='default' AND table='cell_towers' FORMAT TSVWithNames"
name type
name	type
id	UInt32
deleted	UInt8
type	Enum8(\'story\' = 1, \'comment\' = 2, \'poll\' = 3, \'pollopt\' = 4, \'job\' = 5)
by	LowCardinality(String)
time	DateTime
text	String
dead	UInt8
parent	UInt32
poll	UInt32
kids	Array(UInt32)
url	String
score	Int32
title	String
parts	Array(UInt32)
descendants	Int32
---- cell_towers
name	type
radio	Enum8(\'\' = 0, \'CDMA\' = 1, \'GSM\' = 2, \'LTE\' = 3, \'NR\' = 4, \'UMTS\' = 5)
mcc	UInt16
net	UInt16
area	UInt16
cell	UInt64
unit	Int16
lon	Float64
lat	Float64
range	UInt32
samples	UInt32
changeable	UInt8
created	DateTime
updated	DateTime
averageSignal	UInt8
Thinking…

Alright. So, let me write the sample files.

File structure inside /sandbox/repo/clickhouse-demo/:

  1. example.py — Main SDK sample (clickhouse-connect).
    • Mode A (default): hosted public playground, read-only analysis.
    • Mode B: your own ClickHouse Cloud instance via environment variables → full create/insert/query lifecycle.
  2. http_api.sh — Raw HTTP API demo using curl (no dependencies).
  3. requirements.txt
  4. README.md — how to run, how pricing works, troubleshooting.

Let me write example.py carefully.

Main API of clickhouse-connect 1.12:

import clickhouse_connect
client = clickhouse_connect.get_client(host=..., port=8443, username=..., password=..., secure=True, interface='https')
result = client.query('SELECT ...')
result.result_rows, result.column_names
client.command('CREATE TABLE ...')
client.insert('table', rows, column_names=[...])

Playground: host='play.clickhouse.com', port=443, username='play', password='', secure=True, interface='https'.

Note: for clickhouse-connect, the query string parameter user=play for play.clickhouse.com → username='play'.

Let me write it first and then test.

About the read-only analytics demo — 3 queries:

  1. Top HN posters by average score:
SELECT by AS author, count() AS stories, round(avg(score),1) AS avg_score, max(score) AS top_score
FROM default.hackernews
WHERE type = 'story' AND score > 0 AND by != ''
GROUP BY author
ORDER BY stories DESC
LIMIT 10

Hmm, with 37M rows it might be a bit slow, but ClickHouse is fast so it should finish within a few seconds.

  1. Yearly trend:
SELECT toYear(time) AS year, count() AS items, countIf(type='story') AS stories, round(avg(score),1) AS avg_score
FROM default.hackernews
WHERE time > '2006-01-01'
GROUP BY year ORDER BY year DESC LIMIT 8
  1. Something array-ish to show off ClickHouse's features: top story titles by descendants? Or geo queries with cell_towers — radio distribution by country? cell_towers doesn't have a country name, only mcc. Keep it simple:
SELECT radio, count() AS towers, round(avg(samples),0) AS avg_samples
FROM default.cell_towers GROUP BY radio ORDER BY towers DESC

Also include client.query_df to show pandas DataFrame integration? Only if pandas is installed. Let me check whether pandas is installed. clickhouse_connect might require pandas optionally. Let me check, and if pandas is available, use it in a small guarded block.

Additions: server version and elapsed time via query summaries. Show result.summary for read_rows/elapsed.

Mode B (your own Cloud instance): full lifecycle using a temp table:

client.command('CREATE DATABASE IF NOT EXISTS pi_demo')
client.command('DROP TABLE IF EXISTS pi_demo.events')
client.command('''CREATE TABLE pi_demo.events (...) ENGINE = MergeTree ORDER BY (...)''')
client.insert('pi_demo.events', rows, column_names=[...])
queries...
client.command('DROP TABLE ...')

Next, a curl script for the raw HTTP API.

Also, mention pricing in the README and reply. Let me get the pricing facts right. ClickHouse pricing:

  • ClickHouse (the OSS database) is Apache-2.0 licensed and free, self-hosted.
  • ClickHouse Cloud is a managed/hosted service, consumption-based pricing:
    • Development tier: free tier exists — "Development" tier starts at $0, includes $25 in free monthly credits (need to verify). Actual ClickHouse Cloud pricing: tiers are Development, Production, Dedicated. Pricing is per compute unit (CU)/hour plus storage ($/GB-month), billed by the second/hour. There's a free Development tier with limited compute (a small CU included with 256?) — should be cautious here and state it generally, but accurately.

Known facts (as of 2024–2025):

  • ClickHouse Cloud pricing model: pay-as-you-go based on compute units (CU) and storage (GB).
    • Compute: each CU = 1 vCPU + 4GB RAM (Production). Billed per hour by the second.
    • Storage: about $0.06/GB-month? Actually ClickHouse Cloud storage was $0.045/GB/month (Development?) — I'm not confident in the exact numbers.
    • Development tier: $0.10/CU-hour with a minimum billing unit of 0.25 CU? Production: $0.344/CU-hour? Dedicated: custom/annual contract starting at roughly $20K/year.
    • Free: "Free" Development plan gives $25/month in credits? Or rather ClickHouse Cloud offers free credits ($300 in credits for new users?) — there was a "Get started free — no credit card required" with a free Development tier.

I should be careful: state the pricing model (consumption-based: compute units per hour + storage per GB-month + separate billing for backups/egress?), mention tiers, note that the OSS is free, and recommend checking the official pricing calculator for the latest numbers. For accuracy, I can try fetching the pricing page live. Let me curl https://clickhouse.com/pricing and try to extract text. It's probably a Next.js page and JS-heavy, but there may be embedded JSON containing the pricing. Worth trying once — network is available.

Let me fetch it and grep for numbers like "$".

cd /tmp && timeout 45 curl -sSL -m 40 -A 'Mozilla/5.0' 'https://clickhouse.com/pricing' -o pricing.html; echo "EXIT=$?"; wc -c pricing.html; python3 - <<'EOF'
import re,html
s=open('/tmp/pricing.html',encoding='utf-8',errors='ignore').read()
t=re.sub(r'<script[^>]*>.*?</script>',' ',s,flags=re.S)
t=re.sub(r'<style[^>]*>.*?</style>',' ',t,flags=re.S)
t=re.sub(r'<[^>]+>',' ',t)
t=html.unescape(re.sub(r'\s+',' ',t))
for m in re.finditer(r'.{110}\$\s?\d[\d,\.]*.{110}',t):
    print('...',m.group(0).strip(),'\n')
EOF
EXIT=0
EXIT=0
301348 pricing.html
... Pandas-compatible API Get started with ClickHouse Cloud for free We'll get you started on a 30 day trial and $300 credits to spend at your own pace. Start free cloud trial Solutions Use cases Real-time analytics Observabili 

... ay only for the compute and storage you use, metered separately and scaled to zero when idle. Start free with $300 in credits for 30 days. N. Virginia (us-east-1) Basic Great for testing out new ideas or starter projects. Li 

... ation (SSO) using Google or Microsoft Social login Multi-factor authentication (MFA) See all features Storage $25.30 Per 1TB / mo Compute $0.2181 Per unit / hr Estimate your monthly cost ↓ Scale For working with production env 

... based access Private networking Automatic vertical scaling Manual horizontal scaling See all features Storage $25.30 Per 1TB / mo Compute $0.2985 Per unit / hr Estimate your monthly cost ↓ Enterprise For working with productio 

... on / CMEK Scheduled upgrades Consultative migrations guides HIPAA and PCI compliance See all features Storage $25.30 Per 1TB / mo Compute $0.3903 Per unit / hr Estimate your monthly cost ↓ Data transfer for public internet egr 

... download the forever-free open source distribution of ClickHouse -> -> more options Compute and storage from $0.125 Per unit / hr ClickHouse Managed Postgres Fast, scalable, enterprise-grade Postgres, natively integrated with 

... equently asked questions 01 How does the free trial work? Every new ClickHouse Cloud organization starts with $300 in credits to use over 30 days. The trial ends when the credits are used up or the 30 days pass, whichever co 

... from Basic to Scale, or from Scale to Enterprise, at any time. Ready to try ClickHouse Cloud? Start free with $300 in credits for 30 days. Working through a complex setup or committed-spend pricing? Our sales team can help. 

Thinking…

Excellent — live pricing data from clickhouse.com/pricing (N. Virginia us-east-1):

  • Consumption-based pricing: compute and storage are metered separately, scaling to zero when idle.
  • New organizations: $300 credit valid for 30 days (trial).
  • Basic tier: storage $25.30 per 1TB/month, compute $0.2181/unit/hour
  • Scale: compute $0.2985/unit/hour, storage $25.30/TB/month
  • Enterprise: compute $0.3903/unit/hour, storage $25.30/TB/month
  • Data transfer for egress to the public internet: (number needed) — let's extract that part. Also the definition of "unit" (1 unit = 1 vCPU + 4GB RAM?) and the minimum unit (Basic's minimum 0.25 units?). Let's grep the relevant sections: "unit", "Per unit", "minimum", "egress", "Data transfer".

Let's extract more context around the mentions of "Data transfer" and "unit".

cd /tmp && python3 - <<'EOF'
import re,html
s=open('/tmp/pricing.html',encoding='utf-8',errors='ignore').read()
t=re.sub(r'<script[^>]*>.*?</script>',' ',s,flags=re.S)
t=re.sub(r'<style[^>]*>.*?</style>',' ',t,flags=re.S)
t=re.sub(r'<[^>]+>',' ',t)
t=html.unescape(re.sub(r'\s+',' ',t))
for kw in ['Data transfer','minimum','1 unit','per unit','scale to zero','scaled to zero','idle','Development','compute unit','vCPU','GB of memory','free tier','forever']:
    for m in re.finditer(re.escape(kw),t,flags=re.I):
        a=max(0,m.start()-160); b=min(len(t),m.end()+220)
        print(f'### {kw}: ...{t[a:b]}...\n')
        break
EOF
### Data transfer: ...Consultative migrations guides HIPAA and PCI compliance See all features Storage $25.30 Per 1TB / mo Compute $0.3903 Per unit / hr Estimat
### Data transfer: ...Consultative migrations guides HIPAA and PCI compliance See all features Storage $25.30 Per 1TB / mo Compute $0.3903 Per unit / hr Estimate your monthly cost ↓ Data transfer for public internet egress starting at $0.1152 / GB , inter region egress starting at $0.0312 / GB . Trusted by teams at ClickPipes pricing depends on the connector type. See the ClickPipes pricing dimensions for curren...

### minimum: ...ompression Suggested configuration based on your use case Reset For real-time analytics use cases, we estimate a memory-to-storage ratio of approximately 1:25. Minimum compute per replica Maximum compute per replica Number of replicas Active hours per day Always on 24 Backups Enable Backups Backup frequency Retention period Do you know the size of your full and incremental backup? No ...

### per unit: ...n-on authentication (SSO) using Google or Microsoft Social login Multi-factor authentication (MFA) See all features Storage $25.30 Per 1TB / mo Compute $0.2181 Per unit / hr Estimate your monthly cost ↓ Scale For working with production environments, data at scale, or professional use cases. Start free trial Everything in Basic , plus: Unlimited storage Configurable memory Compute-comp...

### scaled to zero: ...Japanese Korean Chinese French Spanish Portuguese Arabic 50.8k Sign in Get Started Pricing Pay only for the compute and storage you use, metered separately and scaled to zero when idle. Start free with $300 in credits for 30 days. N. Virginia (us-east-1) Basic Great for testing out new ideas or starter projects. Limited storage and memory. Start free trial Includes: Up to 1 TB storage 8-12 G...

### idle: ...ese French Spanish Portuguese Arabic 50.8k Sign in Get Started Pricing Pay only for the compute and storage you use, metered separately and scaled to zero when idle. Start free with $300 in credits for 30 days. N. Virginia (us-east-1) Basic Great for testing out new ideas or starter projects. Limited storage and memory. Start free trial Includes: Up to 1 TB storage 8-12 GiB total m...

### Development: ...through a marketplace or directly from ClickHouse, and self-service customers pay monthly by credit card in USD. 08 Which plan should I start with? Basic suits development, testing and small departmental workloads on a single replica with up to 1 TB of storage. Scale is built for production: two or more availability zones, unlimited storage, autoscaling, private networking and compute-com...

### compute unit: ...ing the trial, your credits are still used first; the card is only charged once they are depleted or expire. See the billing overview for details. 02 What is a compute unit? One compute unit is 8 GiB of RAM and 2 vCPUs. Compute is metered per minute and billed per compute-unit-hour at the rate for your plan, cloud provider and region shown above. The calculator on this page applies the rig...

### vCPU: ... card is only charged once they are depleted or expire. See the billing overview for details. 02 What is a compute unit? One compute unit is 8 GiB of RAM and 2 vCPUs. Compute is metered per minute and billed per compute-unit-hour at the rate for your plan, cloud provider and region shown above. The calculator on this page applies the right rate and rounds to the sizes you can actua...

### forever: ...ClickPipes pricing dimensions for current rates. For more information about our billing and pricing please refer to our Billing & Pricing FAQ . Or download the forever-free open source distribution of ClickHouse -> -> more options Compute and storage from $0.125 Per unit / hr ClickHouse Managed Postgres Fast, scalable, enterprise-grade Postgres, natively integrated with ClickHouse. Bu...

Thinking…

Excellent — I've got accurate, up-to-date pricing info:

  • ClickHouse OSS: permanently free (Apache-2.0).
  • ClickHouse Cloud: consumption-based pricing. Compute and storage are metered separately, and idle compute scales to zero.
  • 1 compute unit = 2 vCPU + 8 GiB RAM. Metered per minute, billed per compute-unit-hour.
  • Rates for N. Virginia (us-east-1):
    • Basic: $0.2181/unit/hour, storage $25.30 per 1TB/month, up to 1TB storage, 8–12 GiB total memory
    • Scale: $0.2985/unit/hour, storage $25.30/1TB/month
    • Enterprise: $0.3903/unit/hour, storage $25.30/1TB/month
  • Egress: $0.1152/GB to the public internet, $0.0312/GB for inter-region.
  • Trial: $300 credit over 30 days for every new organization.
  • ClickPipes: pricing depends on connector type.
  • Managed Postgres is a separate product: compute/storage starting at $0.125/unit/hour.

Alright, let's build the sample project. Files:

/sandbox/repo/clickhouse-demo/

  • README.md
  • requirements.txt
  • example.py (SDK demo: defaults to the hosted playground read-only, optionally a full lifecycle via environment variables against your own instance)
  • http_api.sh (raw HTTP API via curl)

First, check whether pandas is available, and after that write example.py and test it.

Also to decide: should I commit the files? The repo has git. Create the files. Committing might be nice too, but the user didn't ask. Create files, and don't commit unless asked... Actually, the repo is a workspace with an initial commit. Should I leave the changes uncommitted, or commit? Keep it simple: create the files and mention that they're uncommitted. Hmm, the common pattern in these tasks: file creation is enough. Leave git alone unless asked? Committing is low risk and useful. Ask the user... No, keep it simple — create the files, don't commit (user can review). Actually, in a fresh workspace repo, committing is often expected as "building an example." If the demo works, commit with a clear message. That's harmless and reversible.

First, check pandas.

python3 -c "import pandas; print('pandas', pandas.__version__)" 2>&1 | tail -2
pandas 3.0.5
pandas 3.0.5
Thinking…

pandas is available. Next, write example.py.

Design of example.py:

#!/usr/bin/env python3
"""
Minimal ClickHouse example using the official Python SDK (clickhouse-connect).

Two modes:
  1. Hosted playground (default) - ...
  2. Your own instance (ClickHouse Cloud or self-hosted) - set env vars -> full write lifecycle
"""
from __future__ import annotations
import os, sys, time
from datetime import datetime, timedelta, timezone
import random

import clickhouse_connect

# --- config
HOST = os.getenv("CLICKHOUSE_HOST", "play.clickhouse.com")
PORT = int(os.getenv("CLICKHOUSE_PORT", "443"))
USER = os.getenv("CLICKHOUSE_USER", "play")
PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
DATABASE = os.getenv("CLICKHOUSE_DATABASE", "default")
READ_ONLY = ... 

Should I add a CLICKHOUSE_MODE that derives readonly, or detect it: if host == play.clickhouse.com then it's playground read-only mode. Better: an explicit env var flag CLICKHOUSE_DEMO_WRITE=1? I'll go with this: own_instance = HOST != "play.clickhouse.com". This is clean and automatic.

Functions:

  • build_client() -> clickhouse_connect.get_client(...)
  • section(title) for pretty-printing
  • run_query(client, sql, title) -> displays column names + rows + summary (read_rows, elapsed)
  • analytics_demo(client) -> 3 read queries + one pandas DataFrame query
  • write_demo(client) -> create DB/table, insert generated rows, query, drop

Write demo schema — a small event analytics table that shows ClickHouse idioms:

CREATE TABLE IF NOT EXISTS pi_demo.page_events
(
    event_time DateTime64(3, 'UTC'),
    user_id    UInt64,
    session_id UUID,
    country    LowCardinality(String),
    device     Enum8('desktop' = 1, 'mobile' = 2, 'tablet' = 3),
    path       String,
    duration_ms UInt32,
    tags       Array(LowCardinality(String)),
    payload    JSON  -- ? JSON type is new; risky. Either use Map(String,String) or skip.
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (country, user_id, event_time)
TTL event_time + INTERVAL 30 DAY

To avoid version issues, maybe skip the JSON type; use Map(String, String). Even on the playground version 26.x it would work, but on the user's older Cloud (e.g. 24.x) JSON is experimental. I'll go with Map(String,String) — safe.

Next, the queries:

  1. Per-country event count and p95 duration:
SELECT country, count() AS events, uniqExact(user_id) AS users, quantile(0.95)(duration_ms) AS p95_ms
FROM pi_demo.page_events GROUP BY country ORDER BY events DESC
  1. Time series:
SELECT toStartOfMinute(event_time) AS minute, count() AS events, avg(duration_ms) AS avg_ms
FROM pi_demo.page_events GROUP BY minute ORDER BY minute
  1. Array usage: top tags via arrayJoin:
SELECT tag, count() AS hits FROM pi_demo.page_events ARRAY JOIN tags AS tag GROUP BY tag ORDER BY hits DESC LIMIT 5

Insert: client.insert(table, rows, column_names=[...]).

Also show an idempotency hint with INSERT ... SELECT? Keep it lightweight.

Cleanup: at the end DROP TABLE (leave the database? drop both).

Note that the write demo requires a writable instance; if run against the playground, display an explanation and skip.

Error handling too: catch clickhouse_connect.driver.exceptions.ClickHouseError and show a clean message + troubleshooting tips.

For the read demo on the playground, use the default.hackernews and default.cell_towers tables. When connecting to your own instance (non-playground), the read demo tables may not exist. So: on your own instance, the write demo is the main thing; and if hackernews exists, also run the analytics queries (check via system.tables). Better structure:

  • analytics_demo(client, dataset_db='default'): first check table existence (query system.tables); if not present, skip with a message.

This way it works on both.

Now let's write the files.

For timing measurement, display the query elapsed from result.summary or from measured wall time. clickhouse-connect's QueryResult has .summary (a dict including elapsed and read_rows) when query settings are on... actually result.summary contains the server summary if query settings are present; simpler: measure time.perf_counter myself, and additionally get read_rows from result.summary.get('read_rows') if present.

Let me write example.py.

Also http_api.sh: curl demo — version, JSONEachRow output, parameterized query via query parameters, and note that writes are 403 on the playground. Use --data-urlencode and ?user=play. Format: JSONCompact, and CSV. Show how to switch host/user/password env vars for your own instance with HTTPS basic auth headers.

Let's write both files, then run example.py to verify.

Network is up and I have a real hosted ClickHouse to talk to. Let me scaffold the example.

File edits

1 file

Completed
Writtenclickhouse-demo/example.pyView changes

/sandbox/repo/clickhouse-demo/example.py

Written content · previous contents were not recorded

#!/usr/bin/env python3
"""Minimal ClickHouse example using the official Python SDK (clickhouse-connect).
Everything runs against a *hosted* ClickHouse over HTTPS. No local server, no
Docker, no long-running commands.
Two modes, chosen automatically from the connection settings:
1. Public playground (default, zero credentials)
host = play.clickhouse.com, user = play
-> read-only. We run analytics against the real datasets ClickHouse hosts
there (~37M Hacker News rows, ~43M cell towers).
2. Your own instance (ClickHouse Cloud trial, or any reachable server)
Set CLICKHOUSE_HOST / CLICKHOUSE_USER / CLICKHOUSE_PASSWORD
-> the script also does the full write lifecycle:
CREATE TABLE -> INSERT -> analytical SELECTs -> DROP TABLE.
Usage:
python3 example.py # playground, read-only analytics
CLICKHOUSE_HOST=<host> \
CLICKHOUSE_USER=<user> \
CLICKHOUSE_PASSWORD=<pw> \
python3 example.py # your instance, full read+write
Env vars (all optional):
CLICKHOUSE_HOST default: play.clickhouse.com
CLICKHOUSE_PORT default: 443
CLICKHOUSE_USER default: play
CLICKHOUSE_PASSWORD default: ""
CLICKHOUSE_DATABASE default: default
CLICKHOUSE_SECURE default: true (set to "false" for plain HTTP servers)
"""
from __future__ import annotations
import os
import random
import sys
import time
import uuid
from datetime import datetime, timedelta, timezone
import clickhouse_connect
from clickhouse_connect.driver.exceptions import ClickHouseError
# --------------------------------------------------------------------------
# Connection settings
# --------------------------------------------------------------------------
PLAYGROUND_HOST = "play.clickhouse.com"
HOST = os.getenv("CLICKHOUSE_HOST", PLAYGROUND_HOST)
PORT = int(os.getenv("CLICKHOUSE_PORT", "443"))
USER = os.getenv("CLICKHOUSE_USER", "play")
PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
DATABASE = os.getenv("CLICKHOUSE_DATABASE", "default")
SECURE = os.getenv("CLICKHOUSE_SECURE", "true").lower() not in ("0", "false", "no")
# The public playground grants no DDL privileges, so only attempt the write
# lifecycle when we are pointed at an instance we actually own.
WRITABLE = HOST != PLAYGROUND_HOST
DEMO_DB = "pi_demo"
DEMO_TABLE = f"{DEMO_DB}.page_events"
COUNTRIES = ["US", "DE", "GB", "JP", "BR", "IN", "FR", "CA"]
DEVICES = ["desktop", "mobile", "tablet"]
PATHS = ["/", "/pricing", "/docs", "/blog", "/signup", "/docs/quickstart"]
TAGS = ["organic", "paid", "referral", "mobile-app", "eu", "trial", "returning"]
# --------------------------------------------------------------------------
# Small helpers
# --------------------------------------------------------------------------
def section(title: str) -> None:
print(f"\n{'=' * 72}\n{title}\n{'=' * 72}")
def build_client():
"""Create an HTTPS client. This is the whole 'connection pool' story:
clickhouse-connect keeps a pool inside the client, so create one and reuse it."""
return clickhouse_connect.get_client(
host=HOST,
port=PORT,
username=USER,
password=PASSWORD,
database=DATABASE,
secure=SECURE,
interface="https" if SECURE else "http",
# Sensible defaults for a demo; tune per workload.
connect_timeout=15,
send_receive_timeout=60,
)
def show(client, sql: str, title: str, params: dict | None = None) -> None:
"""Run a SELECT and print the result plus what the server told us it cost."""
section(title)
print(sql.strip(), "\n")
started = time.perf_counter()
result = client.query(sql, parameters=params)
wall_ms = (time.perf_counter() - started) * 1000
widths = [len(str(c)) for c in result.column_names]
rows = [[("" if v is None else v) for v in row] for row in result.result_rows]
for row in rows:
for i, v in enumerate(row):
widths[i] = max(widths[i], len(str(v)))
def fmt(values):
return " | ".join(str(v).ljust(w) for v, w in zip(values, widths))
print(fmt(result.column_names))
print("-+-".join("-" * w for w in widths))
for row in rows:
print(fmt(row))
summary = result.summary or {}
read_rows = summary.get("read_rows", "n/a")
read_bytes = summary.get("read_bytes", "n/a")
print(
f"\n{len(rows)} rows | wall {wall_ms:.0f} ms | "
f"server scanned {read_rows} rows / {int(read_bytes) / 1e6:.1f} MB"
if str(read_bytes).isdigit()
else f"\n{len(rows)} rows | wall {wall_ms:.0f} ms"
)
def table_exists(client, database: str, table: str) -> bool:
res = client.query(
"SELECT count() FROM system.tables WHERE database = {db:String} AND name = {tbl:String}",
parameters={"db": database, "tbl": table},
)
return int(res.result_rows[0][0]) > 0
# --------------------------------------------------------------------------
# Part 1: read / analytics (works on any instance that has the demo datasets)
# --------------------------------------------------------------------------
def analytics_demo(client) -> None:
if not table_exists(client, "default", "hackernews"):
section("Part 1: analytics demo")
print("Skipped: this instance has no `default.hackernews` dataset.")
print("(The public playground hosts it; a fresh ClickHouse Cloud does not.)")
return
show(
client,
"""
SELECT
by AS author,
count() AS stories,
round(avg(score), 1) AS avg_score,
max(score) AS best_score
FROM default.hackernews
WHERE type = 'story' AND score > 0 AND by != ''
GROUP BY author
ORDER BY stories DESC
LIMIT 8
""",
"Part 1a: GROUP BY over ~37M rows (aggregation is ClickHouse's bread and butter)",
)
show(
client,
"""
SELECT
toYear(time) AS year,
count() AS items,
countIf(type = 'story') AS stories,
round(quantile(0.95)(score), 1) AS p95_score
FROM default.hackernews
WHERE time >= toDateTime('2015-01-01')
GROUP BY year
ORDER BY year DESC
LIMIT 6
""",
"Part 1b: time bucketing + quantiles (quantile is approximate and very fast)",
)
show(
client,
"""
SELECT
radio AS technology,
count() AS towers,
round(avg(averageSignal), 1) AS avg_signal
FROM default.cell_towers
GROUP BY technology
ORDER BY towers DESC
""",
"Part 1c: Enum8 column, second dataset (~43M rows)",
)
# Server-side parameter binding: values are sent separately from the SQL,
# so no string interpolation / injection risk.
show(
client,
"""
SELECT id, time, score, left(title, 60) AS title
FROM default.hackernews
WHERE type = 'story' AND score >= {min_score:Int32}
ORDER BY score DESC
LIMIT 5
""",
"Part 1d: parameterised query (bind variables, not string building)",
params={"min_score": 3000},
)
# Optional: pandas integration (query_df) if pandas is installed.
try:
import pandas # noqa: F401
except ImportError:
print("\n(pandas not installed - skipping query_df demo)")
return
section("Part 1e: client.query_df() -> pandas DataFrame")
df = client.query_df(
"""
SELECT toStartOfMonth(time) AS month, count() AS items
FROM default.hackernews
WHERE time >= toDateTime('2020-01-01')
GROUP BY month
ORDER BY month DESC
LIMIT 8
"""
)
print(df.to_string(index=False))
print(f"\ndtypes:\n{df.dtypes}")
# --------------------------------------------------------------------------
# Part 2: write lifecycle (needs an instance where we have DDL rights)
# --------------------------------------------------------------------------
def generate_rows(n: int = 5_000) -> list[list]:
"""Fake page-view events, newest first-ish, spread over the last hour."""
rng = random.Random(42) # deterministic so the demo output is stable
now = datetime.now(timezone.utc).replace(tzinfo=None)
rows = []
for i in range(n):
rows.append(
[
now - timedelta(seconds=rng.randint(0, 3600)), # event_time
rng.randint(1, 500), # user_id
uuid.uuid4(), # session_id
rng.choice(COUNTRIES), # country
rng.choice(DEVICES), # device
rng.choice(PATHS), # path
rng.randint(20, 8_000), # duration_ms
rng.sample(TAGS, k=rng.randint(1, 3)), # tags (Array)
{"plan": rng.choice(["free", "pro"]), "v": "2"}, # Map(String,String)
]
)
return rows
def write_demo(client) -> None:
section("Part 2: write lifecycle (CREATE -> INSERT -> SELECT -> DROP)")
client.command(f"CREATE DATABASE IF NOT EXISTS {DEMO_DB}")
client.command(f"DROP TABLE IF EXISTS {DEMO_TABLE}")
client.command(
f"""
CREATE TABLE {DEMO_TABLE}
(
event_time DateTime64(3, 'UTC'),
user_id UInt64,
session_id UUID,
country LowCardinality(String),
device Enum8('desktop' = 1, 'mobile' = 2, 'tablet' = 3),
path String,
duration_ms UInt32,
tags Array(LowCardinality(String)),
attributes Map(String, String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (country, user_id, event_time)
TTL toDateTime(event_time) + INTERVAL 30 DAY
"""
)
print(f"created {DEMO_TABLE} (MergeTree, partitioned by month, TTL 30 days)")
rows = generate_rows()
columns = [
"event_time",
"user_id",
"session_id",
"country",
"device",
"path",
"duration_ms",
"tags",
"attributes",
]
started = time.perf_counter()
client.insert(DEMO_TABLE, rows, column_names=columns)
ms = (time.perf_counter() - started) * 1000
print(
f"inserted {len(rows):,} rows in {ms:.0f} ms "
f"({len(rows) / max(ms, 1) * 1000:,.0f} rows/s) - native columnar insert, no SQL text"
)
show(
client,
f"""
SELECT
country,
count() AS events,
uniqExact(user_id) AS users,
round(quantile(0.95)(duration_ms)) AS p95_ms,
round(avg(duration_ms)) AS avg_ms
FROM {DEMO_TABLE}
GROUP BY country
ORDER BY events DESC
""",
"Part 2a: aggregate what we just wrote",
)
show(
client,
f"""
SELECT
toStartOfTenMinutes(event_time) AS bucket,
count() AS events,
countIf(device = 'mobile') AS mobile_events
FROM {DEMO_TABLE}
GROUP BY bucket
ORDER BY bucket
""",
"Part 2b: time-series rollup",
)
show(
client,
f"""
SELECT
tag,
count() AS events,
uniqExact(user_id) AS users
FROM {DEMO_TABLE}
ARRAY JOIN tags AS tag
GROUP BY tag
ORDER BY events DESC
LIMIT 5
""",
"Part 2c: ARRAY JOIN (explode an Array column into rows)",
)
show(
client,
f"""
SELECT
attributes['plan'] AS plan,
count() AS events,
round(avg(duration_ms)) AS avg_ms
FROM {DEMO_TABLE}
GROUP BY plan
ORDER BY events DESC
""",
"Part 2d: read out of a Map column",
)
show(
client,
f"""
SELECT
name,
engine,
total_rows,
formatReadableSize(total_bytes) AS on_disk,
formatReadableSize(data_compressed_bytes) AS compressed
FROM system.tables
WHERE database = '{DEMO_DB}' AND name = 'page_events'
""",
"Part 2e: what the engine did with our data (compression ratio is the fun part)",
)
client.command(f"DROP TABLE IF EXISTS {DEMO_TABLE}")
print(f"cleaned up: dropped {DEMO_TABLE}")
# --------------------------------------------------------------------------
def main() -> int:
section("Connecting")
print(
f"host : {HOST}:{PORT} ({'https' if SECURE else 'http'})\n"
f"user : {USER}\n"
f"database : {DATABASE}\n"
f"mode : {'read + write' if WRITABLE else 'read-only (public playground)'}"
)
try:
client = build_client()
version = client.server_version
tz = client.server_tz
print(f"server : ClickHouse {version} ({tz})")
except ClickHouseError as exc:
print(f"\nCould not connect: {exc}", file=sys.stderr)
print(
"\nTroubleshooting:\n"
" - ClickHouse Cloud: use the HTTPS endpoint on port 8443 and the\n"
" 'default' user + password from the console (SQL console -> connect).\n"
" - Check CLICKHOUSE_HOST has no scheme/path, e.g. abc123.eu-west-1.aws.clickhouse.cloud\n"
" - Self-hosted plain HTTP on 8123: set CLICKHOUSE_SECURE=false and CLICKHOUSE_PORT=8123",
file=sys.stderr,
)
return 1
try:
analytics_demo(client)
if WRITABLE:
write_demo(client)
else:
section("Part 2: write lifecycle")
print(
"Skipped: play.clickhouse.com is read-only "
"(CREATE TABLE returns ACCESS_DENIED, code 497).\n"
"Point the script at your own instance to see it:\n\n"
" CLICKHOUSE_HOST=<your-host> CLICKHOUSE_USER=<user> \\\n"
" CLICKHOUSE_PASSWORD=<password> python3 example.py\n\n"
"A free ClickHouse Cloud trial ($300 of credits / 30 days) is enough."
)
section("Done")
print("All queries ran over HTTPS against a hosted ClickHouse. No local server needed.")
return 0
except ClickHouseError as exc:
print(f"\nClickHouse error: {exc}", file=sys.stderr)
return 1
finally:
try:
client.close()
except Exception:
pass
if __name__ == "__main__":
raise SystemExit(main())

File edits

1 file

Completed
Writtenclickhouse-demo/http_api.shView changes

/sandbox/repo/clickhouse-demo/http_api.sh

Written content · previous contents were not recorded

#!/usr/bin/env bash
# Zero-dependency ClickHouse demo: the raw HTTP interface with curl.
#
# ClickHouse speaks HTTP natively (8443/TLS or 8123/plain), so any language with
# an HTTP client works. The SDKs are just convenience wrappers over this.
#
# ./http_api.sh # public playground (read-only, no credentials)
# CLICKHOUSE_HOST=<host> CLICKHOUSE_USER=<user> CLICKHOUSE_PASSWORD=<pw> ./http_api.sh
set -euo pipefail
HOST="${CLICKHOUSE_HOST:-play.clickhouse.com}"
PORT="${CLICKHOUSE_PORT:-443}"
USER_NAME="${CLICKHOUSE_USER:-play}"
PASSWORD="${CLICKHOUSE_PASSWORD:-}"
if [[ "$HOST" == "play.clickhouse.com" ]]; then
URL="https://${HOST}:${PORT}/?user=${USER_NAME}"
AUTH=()
else
URL="https://${HOST}:${PORT}/"
AUTH=(-H "X-ClickHouse-User: ${USER_NAME}" -H "X-ClickHouse-Key: ${PASSWORD}")
fi
run() { # run "<label>" "<sql>" [extra curl args...]
local label="$1" sql="$2"; shift 2
printf '\n== %s\n' "$label"
printf '%s\n\n' "$sql"
curl -sS --max-time 60 "${AUTH[@]}" "$@" "$URL" --data-binary "$sql"
}
echo "host: ${HOST}:${PORT} user: ${USER_NAME}"
run "1. server version (plain text is the default format)" "SELECT version()"
run "2. JSONCompact output (?param_format=... or FORMAT clause)" \
"SELECT number AS n, number * number AS square
FROM system.numbers
LIMIT 5
FORMAT JSONCompact"
run "3. aggregation over a hosted dataset (~37M rows)" \
"SELECT toYear(time) AS year, count() AS items, round(avg(score), 1) AS avg_score
FROM default.hackernews
WHERE time >= toDateTime('2019-01-01')
GROUP BY year
ORDER BY year DESC
LIMIT 6
FORMAT PrettyCompactMonoBlock"
run "4. bind parameters via the query string (never interpolate user input)" \
"SELECT id, score, left(title, 50) AS title
FROM default.hackernews
WHERE type = 'story' AND score >= {min_score:Int32}
ORDER BY score DESC
LIMIT 3
FORMAT TSVWithNames" \
--get --data-urlencode 'query=SELECT id, score, left(title, 50) AS title
FROM default.hackernews
WHERE type = '"'"'story'"'"' AND score >= {min_score:Int32}
ORDER BY score DESC
LIMIT 3
FORMAT TSVWithNames' --data-urlencode 'param_min_score=4000'
run "5. query metadata: how many rows/bytes the server actually read" \
"SELECT count() FROM default.hackernews
SETTINGS send_progress_in_http_headers = 0
FORMAT JSONCompact"
run "6. system tables = free observability (slow query log, parts, merges)" \
"SELECT query_duration_ms, read_rows, formatReadableSize(read_bytes) AS read, query
FROM system.query_log
WHERE type = 'QueryFinish' AND query NOT LIKE '%query_log%'
ORDER BY event_time DESC
LIMIT 3
FORMAT Vertical" || true
if [[ "$HOST" == "play.clickhouse.com" ]]; then
printf '\n== 7. writes are blocked on the public playground (expected)\n'
curl -sS --max-time 30 "$URL" --data-binary \
'CREATE TABLE default.nope_from_demo (x UInt8) ENGINE = Memory' || true
printf '\n\nUse your own ClickHouse Cloud instance for the write path.\n'
fi
Thinking…

Alright, let's test example.py. Caveat: item 4 in http_api.sh is a bit messy (the run function is passing SQL while also being given --get --data-urlencode query... as curl arguments, so both --data-binary and --get end up being included — contradictory). Let's fix http_api.sh: make item 4 a standalone curl using --get and param_min_score.

First test example.py, then fix the shell script.

cd /sandbox/repo/clickhouse-demo && chmod +x http_api.sh example.py && timeout 240 python3 example.py 2>&1 | tail -90
FROM default.hackernews
        FROM default.hackernews
        WHERE time >= toDateTime('2015-01-01')
        GROUP BY year
        ORDER BY year DESC
        LIMIT 6 

year | items   | stories | p95_score
-----+---------+---------+----------
2023 | 2969111 | 256791  | 1.0      
2022 | 4448191 | 383586  | 1.0      
2021 | 4155060 | 387194  | 2.0      
2020 | 3673138 | 428693  | 2.0      
2019 | 3127712 | 371331  | 2.0      
2018 | 2754279 | 368398  | 2.0      

6 rows | wall 106 ms | server scanned 37171166 rows / 290.5 MB

========================================================================
Part 1c: Enum8 column, second dataset (~43M rows)
========================================================================
SELECT
            radio                          AS technology,
            count()                        AS towers,
            round(avg(averageSignal), 1)   AS avg_signal
        FROM default.cell_towers
        GROUP BY technology
        ORDER BY towers DESC 

technology | towers   | avg_signal
-----------+----------+-----------
UMTS       | 20686487 | 0.0       
LTE        | 12101148 | 0.0       
GSM        | 9931312  | 0.0       
CDMA       | 556344   | 0.0       
NR         | 867      | 0.0       

5 rows | wall 97 ms | server scanned 43276158 rows / 86.6 MB

========================================================================
Part 1d: parameterised query (bind variables, not string building)
========================================================================
SELECT id, time, score, left(title, 60) AS title
        FROM default.hackernews
        WHERE type = 'story' AND score >= {min_score:Int32}
        ORDER BY score DESC
        LIMIT 5 

id       | time                | score | title                      
---------+---------------------+-------+----------------------------
16582136 | 2018-03-14 03:50:30 | 6015  | Stephen Hawking has died   
11116274 | 2016-02-17 08:38:37 | 5771  | A Message to Our Customers 
3078128  | 2011-10-05 23:42:23 | 4338  | Steve Jobs has passed away.
37011324 | 2023-08-05 12:23:27 | 4310  | Bram Moolenaar has died    
31261533 | 2022-05-04 15:06:41 | 4298  | Mechanical Watch           

5 rows | wall 108 ms | server scanned 37212126 rows / 187.3 MB

========================================================================
Part 1e: client.query_df() -> pandas DataFrame
========================================================================
     month  items
2023-08-01 220598
2023-07-01 406196
2023-06-01 398266
2023-05-01 378428
2023-04-01 372245
2023-03-01 418710
2023-02-01 372527
2023-01-01 402141

dtypes:
month    datetime64[s]
items           uint64
dtype: object

========================================================================
Part 2: write lifecycle
========================================================================
Skipped: play.clickhouse.com is read-only (CREATE TABLE returns ACCESS_DENIED, code 497).
Point the script at your own instance to see it:

  CLICKHOUSE_HOST=<your-host> CLICKHOUSE_USER=<user> \
  CLICKHOUSE_PASSWORD=<password> python3 example.py

A free ClickHouse Cloud trial ($300 of credits / 30 days) is enough.

========================================================================
Done
========================================================================
All queries ran over HTTPS against a hosted ClickHouse. No local server needed.
Thinking…

The read path works end-to-end. Next, what needs to be verified is:

  1. Whether Part 1a's output (the beginning got cut off in the tail output) works — check the head portion.
  2. Whether write_demo's code path actually works. I don't have a writable hosted instance... but I can test the write path's SQL/schema validity without violating the "don't use local services" constraint — hmm. Running a local clickhouse-server would violate the instructions.

Alternative: use clickhouse-local? That's also a local binary and isn't installed in the first place.

Can I verify the write_demo logic without a writable server? Options:

  • Static check: syntax review + verifying that all SQL is valid ClickHouse syntax. Risky spots: DateTime64(3,'UTC'), Enum8, Map, ARRAY JOIN, TTL toDateTime(event_time) + INTERVAL — toDateTime(event_time) on DateTime64 works. ORDER BY (country, user_id, event_time) — sorting on DateTime64 is fine.
  • I can verify SQL parsing against the hosted playground! The playground permits SELECT and EXPLAIN. Run EXPLAIN AST/SYNTAX or EXPLAIN PLAN on the CREATE TABLE statement? EXPLAIN on DDL: does ClickHouse support EXPLAIN AST CREATE TABLE ...? EXPLAIN AST works on any query (parses into AST) — yes, EXPLAIN AST supports arbitrary queries. Also EXPLAIN SYNTAX. This lets me verify syntax without executing the DDL.

Also, SELECT queries against pi_demo.page_events will fail on the playground (table doesn't exist), but I can rewrite the same SELECT text against playground tables that have equivalent columns to validate them... more simply: EXPLAIN AST on those SELECTs (parse-only, so unresolvable identifiers won't be detected).

Better approach: validate SELECT semantics by creating equivalent queries against playground tables that have similar columns? Overkill. Practical approach:

  1. Run EXPLAIN AST/EXPLAIN SYNTAX on the CREATE TABLE and each Part 2 SELECT to catch syntax errors.
  2. Also test whether client.insert's signature/column handling works — can't do that without a writable table. However, I can test client.insert failure handling... no, can't do it.

Actually, can I find a writable hosted ClickHouse without signing up? There's ClickHouse's "docs sample data" — not writable. Maybe there's a sandbox like https://sql.clickhouse.com? Not writable.

Hmm — what about the default database on the playground for user play... confirmed to be denied.

Do EXPLAIN-based validation, and do a Python dry-run of generate_rows() to check row/column count consistency and types (e.g., does uuid work, datetime is naive, etc.). That gives reasonable confidence. And in the README, clearly state that the write path is untested due to lack of writable credentials, and that I validated the SQL with EXPLAIN. That's honest.

Also test the following:

  • EXPLAIN SYNTAX CREATE TABLE ... — syntax normalization.
  • Verify that the Enum8 insertion mapping with string values ('desktop') works — standard and documented behavior.
  • Map(String,String) insertion with a Python dict — documented.
  • Array insertion with a Python list — documented.

Let me run the EXPLAIN checks and the dry-run of generate_rows.

I'll also fix item 4 of http_api.sh, then test the shell script.

First, let me look at the head of example.py's output (Part 1a).

cd /sandbox/repo/clickhouse-demo && timeout 240 python3 example.py 2>&1 | head -45
========================================================================

========================================================================
Connecting
========================================================================
host     : play.clickhouse.com:443 (https)
user     : play
database : default
mode     : read-only (public playground)
server   : ClickHouse 26.10.3.7 (UTC)

========================================================================
Part 1a: GROUP BY over ~37M rows (aggregation is ClickHouse's bread and butter)
========================================================================
SELECT
            by                    AS author,
            count()               AS stories,
            round(avg(score), 1)  AS avg_score,
            max(score)            AS best_score
        FROM default.hackernews
        WHERE type = 'story' AND score > 0 AND by != ''
        GROUP BY author
        ORDER BY stories DESC
        LIMIT 8 

author    | stories | avg_score | best_score
----------+---------+-----------+-----------
rbanffy   | 28483   | 8.6       | 1793      
Tomte     | 21921   | 12.6      | 1336      
tosh      | 17551   | 17.2      | 2856      
pseudolus | 15377   | 19.2      | 1336      
ingve     | 13697   | 29.6      | 2776      
jonbaer   | 13563   | 9.7       | 947       
bookofjoe | 12106   | 10.5      | 1382      
mooreds   | 11140   | 10.1      | 1683      

8 rows | wall 140 ms | server scanned 37171166 rows / 435.4 MB

========================================================================
Part 1b: time bucketing + quantiles (quantile is approximate and very fast)
========================================================================
SELECT
            toYear(time)          AS year,
            count()               AS items,
            countIf(type = 'story') AS stories,
            round(quantile(0.95)(score), 1) AS p95_score
cd /sandbox/repo/clickhouse-demo && python3 - <<'EOF'
# Dry-run the write-path plumbing without a writable server:
# 1) row/column shape + python types for client.insert
# 2) every Part-2 statement parsed by the real hosted server via EXPLAIN AST/SYNTAX
import example as ex, uuid, datetime
rows = ex.generate_rows(1000)
cols = ["event_time","user_id","session_id","country","device","path","duration_ms","tags","attributes"]
assert all(len(r) == len(cols) for r in rows), "column count mismatch"
print("rows:", len(rows), "| cols:", len(cols))
r = rows[0]
for c, v in zip(cols, r):
    print(f"  {c:12} {type(v).__name__:10} {str(v)[:48]}")
assert isinstance(r[2], uuid.UUID) and isinstance(r[0], datetime.datetime)
print("shape/type check OK")
EOF
rows: 1000 | cols: 9
rows: 1000 | cols: 9
  event_time   datetime   2026-11-08 01:27:00.054728
  user_id      int        58
  session_id   UUID       70793702-326f-4dce-bcd3-42d46a686c88
  country      str        US
  device       str        tablet
  path         str        /docs
  duration_ms  int        2026
  tags         list       ['paid']
  attributes   dict       {'plan': 'free', 'v': '2'}
shape/type check OK
Thinking…

Next, verify the Part-2 SQL statements against a hosted server using EXPLAIN AST and EXPLAIN SYNTAX. This way I can confirm the parse/normalize validity without executing any DDL.

Note: EXPLAIN SYNTAX CREATE TABLE — is this supported? EXPLAIN SYNTAX works on SELECT and some queries. Let's try both. If EXPLAIN SYNTAX doesn't support DDL, EXPLAIN AST should work.

Also verify the Part-2 SELECTs with EXPLAIN AST (parse check).

Furthermore, should I verify the analyzer's checks against CREATE TABLE with EXPLAIN PLAN? Not supported for DDL.

Another way to deeply verify the CREATE TABLE definition without DDL privileges: nothing simple. But actually — a trick: CREATE TABLE ... AS SELECT requires privileges. Hmm.

Or, can I verify the DDL semantic correctness using a playground system function? Nope, impossible.

Another trick: many ClickHouse playground-type instances allow temporary Memory tables for the play user? Already got ACCESS_DENIED on default. What about creating in my own database? CREATE DATABASE will probably also be denied. Let me quickly test CREATE DATABASE to confirm — for accurate messaging.

Let me run the EXPLAIN checks and the CREATE DATABASE investigation in a single script.

cd /sandbox/repo/clickhouse-demo && python3 - <<'EOF'
# Validate every write-path statement against the real hosted ClickHouse parser
# (EXPLAIN AST / EXPLAIN SYNTAX do not need DDL privileges).
import clickhouse_connect
from clickhouse_connect.driver.exceptions import ClickHouseError
import example as ex

client = clickhouse_connect.get_client(host='play.clickhouse.com', port=443,
                                       username='play', password='', secure=True,
                                       interface='https')
ddl = f"""
CREATE TABLE {ex.DEMO_TABLE}
(
    event_time  DateTime64(3, 'UTC'),
    user_id     UInt64,
    session_id  UUID,
    country     LowCardinality(String),
    device      Enum8('desktop' = 1, 'mobile' = 2, 'tablet' = 3),
    path        String,
    duration_ms UInt32,
    tags        Array(LowCardinality(String)),
    attributes  Map(String, String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (country, user_id, event_time)
TTL toDateTime(event_time) + INTERVAL 30 DAY
"""
selects = {
 '2a': f"SELECT country, count() AS events, uniqExact(user_id) AS users, round(quantile(0.95)(duration_ms)) AS p95_ms, round(avg(duration_ms)) AS avg_ms FROM {ex.DEMO_TABLE} GROUP BY country ORDER BY events DESC",
 '2b': f"SELECT toStartOfTenMinutes(event_time) AS bucket, count() AS events, countIf(device = 'mobile') AS mobile_events FROM {ex.DEMO_TABLE} GROUP BY bucket ORDER BY bucket",
 '2c': f"SELECT tag, count() AS events, uniqExact(user_id) AS users FROM {ex.DEMO_TABLE} ARRAY JOIN tags AS tag GROUP BY tag ORDER BY events DESC LIMIT 5",
 '2d': f"SELECT attributes['plan'] AS plan, count() AS events, round(avg(duration_ms)) AS avg_ms FROM {ex.DEMO_TABLE} GROUP BY plan ORDER BY events DESC",
}
for kind in ('AST', 'SYNTAX'):
    try:
        client.command(f"EXPLAIN {kind} {ddl}")
        print(f"EXPLAIN {kind} on CREATE TABLE: OK")
    except ClickHouseError as e:
        print(f"EXPLAIN {kind} on CREATE TABLE: {str(e)[:200]}")
for k, sql in selects.items():
    try:
        client.command(f"EXPLAIN AST {sql}")
        print(f"EXPLAIN AST select {k}: OK")
    except ClickHouseError as e:
        print(f"EXPLAIN AST select {k}: FAIL {str(e)[:160]}")
# confirm the playground really has no DDL path at all (for honest messaging)
for stmt in ("CREATE DATABASE pi_demo", "CREATE TEMPORARY TABLE tmp_probe (x UInt8)"):
    try:
        client.command(stmt); print(f"{stmt}: ALLOWED")
    except ClickHouseError as e:
        print(f"{stmt}: denied -> code {str(e).split('Code: ')[1][:3] if 'Code: ' in str(e) else str(e)[:60]}")
client.close()
EOF
EXPLAIN AST on CREATE TABLE: OK
EXPLAIN AST on CREATE TABLE: OK
EXPLAIN SYNTAX on CREATE TABLE: OK
EXPLAIN AST select 2a: OK
EXPLAIN AST select 2b: OK
EXPLAIN AST select 2c: OK
EXPLAIN AST select 2d: OK
CREATE DATABASE pi_demo: denied -> code 497
CREATE TEMPORARY TABLE tmp_probe (x UInt8): denied -> code 497
Thinking…

All SQL parsed successfully on an actual ClickHouse 26.10 server. On the playground, DDL is completely denied (even temporary tables are not allowed).

Next, I'll fix item 4 in http_api.sh (the curl argument mixing issue) and simplify the run() helper. Rewriting http_api.sh.

Design:

  • run() takes a label and SQL, POSTing via --data-binary.
  • For the parameterized demo, use --get and --data-urlencode 'query=...' and 'param_min_score=4000'.

Rewriting it cleanly.

File edits

1 file

Completed
Writtenclickhouse-demo/http_api.shView changes

/sandbox/repo/clickhouse-demo/http_api.sh

Written content · previous contents were not recorded

#!/usr/bin/env bash
# Zero-dependency ClickHouse demo: the raw HTTP interface with curl.
#
# ClickHouse speaks HTTP natively (8443/TLS or 8123/plain), so any language with
# an HTTP client works; the official SDKs are convenience wrappers over this.
#
# ./http_api.sh # public playground (read-only, no credentials)
# CLICKHOUSE_HOST=<host> \
# CLICKHOUSE_USER=<user> \
# CLICKHOUSE_PASSWORD=<pw> ./http_api.sh # your own instance
set -euo pipefail
HOST="${CLICKHOUSE_HOST:-play.clickhouse.com}"
PORT="${CLICKHOUSE_PORT:-443}"
USER_NAME="${CLICKHOUSE_USER:-play}"
PASSWORD="${CLICKHOUSE_PASSWORD:-}"
if [[ "$HOST" == "play.clickhouse.com" ]]; then
URL="https://${HOST}:${PORT}/?user=${USER_NAME}"
AUTH=()
else
URL="https://${HOST}:${PORT}/"
AUTH=(-H "X-ClickHouse-User: ${USER_NAME}" -H "X-ClickHouse-Key: ${PASSWORD}")
fi
post() { # post "<label>" "<sql>"
printf '\n== %s\n\n%s\n\n' "$1" "$2"
curl -sS --max-time 60 "${AUTH[@]}" "$URL" --data-binary "$2"
}
get() { # get "<label>" "<sql>" "<param_name>=<value>"
printf '\n== %s\n\n%s\n\n' "$1" "$2"
curl -sS --max-time 60 "${AUTH[@]}" "$URL" --get \
--data-urlencode "query=$2" --data-urlencode "$3"
}
echo "host: ${HOST}:${PORT} user: ${USER_NAME}"
post "1. server version (TabSeparated is the default output format)" \
"SELECT version()"
post "2. ask for JSON with a FORMAT clause (JSONCompact, JSONEachRow, CSV, Parquet...)" \
"SELECT number AS n, number * number AS square
FROM system.numbers
LIMIT 5
FORMAT JSONCompact"
post "3. aggregation over a hosted dataset (~37M rows)" \
"SELECT toYear(time) AS year, count() AS items, round(avg(score), 1) AS avg_score
FROM default.hackernews
WHERE time >= toDateTime('2019-01-01')
GROUP BY year
ORDER BY year DESC
LIMIT 6
FORMAT PrettyCompactMonoBlock"
get "4. bind parameters on the query string (never interpolate user input into SQL)" \
"SELECT id, score, left(title, 50) AS title
FROM default.hackernews
WHERE type = 'story' AND score >= {min_score:Int32}
ORDER BY score DESC
LIMIT 3
FORMAT TSVWithNames" \
"param_min_score=4000"
post "5. server-side query settings travel as query-string args / SETTINGS clause" \
"SELECT count() AS rows_read
FROM default.hackernews
SETTINGS max_threads = 2, max_memory_usage = 1000000000
FORMAT JSONCompact"
post "6. system tables are free observability (query_log, parts, merges, metrics)" \
"SELECT query_duration_ms, read_rows, formatReadableSize(read_bytes) AS read, left(query, 60) AS query
FROM system.query_log
WHERE type = 'QueryFinish' AND query NOT LIKE '%query_log%'
ORDER BY event_time DESC
LIMIT 3
FORMAT Vertical"
if [[ "$HOST" == "play.clickhouse.com" ]]; then
printf '\n== 7. writes are blocked on the public playground (expected: ACCESS_DENIED 497)\n\n'
curl -sS --max-time 30 "$URL" --data-binary \
'CREATE TABLE default.nope_from_demo (x UInt8) ENGINE = Memory' || true
printf '\n\nPoint CLICKHOUSE_HOST at your own ClickHouse Cloud instance for the write path.\n'
fi

File edits

1 file

Completed
Writtenclickhouse-demo/requirements.txtView changes

/sandbox/repo/clickhouse-demo/requirements.txt

Written content · previous contents were not recorded

clickhouse-connect>=0.7.0
Thinking…

Wait a sec, pandas 3.0.5 is installed but does clickhouse-connect work? Yes, query_df worked.

Next, test http_api.sh. Note: item 5 which includes SETTINGS — no problem. Item 6 uses system.query_log — the playground user may not have access to query_log. Let's check. If access is denied, it'll error out and set -euo pipefail will halt the script. Let's test it and adjust (add || true, or pick a different system table).

cd /sandbox/repo/clickhouse-demo && chmod +x http_api.sh && timeout 180 ./http_api.sh 2>&1 | tail -60; echo "SCRIPT_EXIT=${PIPESTATUS[0]}"
Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.numbers. (ACCESS_DENIED) (versio
Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.numbers. (ACCESS_DENIED) (version 26.10.3.7 (official build))

== 3. aggregation over a hosted dataset (~37M rows)

SELECT toYear(time) AS year, count() AS items, round(avg(score), 1) AS avg_score
FROM default.hackernews
WHERE time >= toDateTime('2019-01-01')
GROUP BY year
ORDER BY year DESC
LIMIT 6
FORMAT PrettyCompactMonoBlock

   ┌─year─┬───items─┬─avg_score─┐
1. │ 2023 │ 2969111 │       1.5 │
2. │ 2022 │ 4448191 │       1.5 │
3. │ 2021 │ 4155060 │       1.5 │
4. │ 2020 │ 3673138 │       1.7 │
5. │ 2019 │ 3127712 │       1.8 │
   └──────┴─────────┴───────────┘

== 4. bind parameters on the query string (never interpolate user input into SQL)

SELECT id, score, left(title, 50) AS title
FROM default.hackernews
WHERE type = 'story' AND score >= {min_score:Int32}
ORDER BY score DESC
LIMIT 3
FORMAT TSVWithNames

id	score	title
16582136	6015	Stephen Hawking has died
11116274	5771	A Message to Our Customers
3078128	4338	Steve Jobs has passed away.

== 5. server-side query settings travel as query-string args / SETTINGS clause

SELECT count() AS rows_read
FROM default.hackernews
SETTINGS max_threads = 2, max_memory_usage = 1000000000
FORMAT JSONCompact

Code: 164. DB::Exception: Cannot modify 'max_threads' setting in readonly mode. (READONLY) (version 26.10.3.7 (official build))

== 6. system tables are free observability (query_log, parts, merges, metrics)

SELECT query_duration_ms, read_rows, formatReadableSize(read_bytes) AS read, left(query, 60) AS query
FROM system.query_log
WHERE type = 'QueryFinish' AND query NOT LIKE '%query_log%'
ORDER BY event_time DESC
LIMIT 3
FORMAT Vertical

Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.query_log. (ACCESS_DENIED) (version 26.10.3.7 (official build))

== 7. writes are blocked on the public playground (expected: ACCESS_DENIED 497)

Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant CREATE TABLE ON default.nope_from_demo. (ACCESS_DENIED) (version 26.10.3.7 (official build))


Point CLICKHOUSE_HOST at your own ClickHouse Cloud instance for the write path.
SCRIPT_EXIT=0
Thinking…

The playground is a readonly=2 style restricted user: system.numbers is denied, settings changes are denied, system.query_log is denied. Need to fix items 2, 5, and 6 so the script works cleanly in the playground too.

Replacements:

  • Item 2 (JSON format): use an actual dataset instead of system.numbers. Example: SELECT id, score, left(title,40) AS title FROM default.hackernews WHERE score > 3000 ORDER BY score DESC LIMIT 3 FORMAT JSONCompact. Or generate numbers without using system.numbers: SELECT arrayJoin([1,2,3,4,5]) AS n — works without system tables. To demonstrate a generator function, use numbers… simplest is SELECT number... isn't allowed. Use arrayJoin or a dataset. I'll go with arrayJoin — shows off ClickHouse's array functions.
  • Item 5 (settings): in playground readonly, settings cannot be changed. Two options: show settings usage but tolerate failure with a clear note; or use settings allowed even in readonly (readonly=2 allows some settings? Apparently max_threads is denied). Better approach: change item 5 to a demo of query string parameters that are allowed: e.g. ?default_format=CSV as a URL parameter (this is an HTTP-level parameter, not a server setting) — this works: curl "$URL&default_format=CSV". This is a good HTTP API lesson. Add a note that server-side SETTINGS require a non-readonly user, and on your own instance you'd use SETTINGS ....
  • Item 6 (system tables): playground denies query_log. Which system tables can play read? Previously system.tables and system.columns worked. So use system.tables / system.columns for the observability demo, and mention query_log/parts for your own instance. Verify that system.parts and system.metrics are accessible? Let's test a few: system.tables (works), system.columns (works), system.parts, system.events, system.metrics, system.settings, system.functions, system.formats.

Let's test which ones are readable, then rewrite items 2, 5, 6 accordingly. Additionally add an || true guard so the script doesn't die under pipefail… actually, with set -e, if curl succeeds (exit code 0) and returns error text, the script continues. The failure above returned HTTP 200 with error text? curl's exit code was 0, so the script ran all the way through (SCRIPT_EXIT=0). Still, make sure everything succeeds cleanly.

Let's verify access to the system tables.

cd /tmp && for t in tables columns parts events metrics settings functions formats numbers one clusters processes query_log; do printf '%-12s ' "$t"; timeout 20 curl -sS -m 15 'https://play.clickhouse.com/?user=play' --data "SELECT count() FROM system.$t" 2>&1 | head -1; done; echo "--- format via url param:"; timeout 20 curl -sS -m 15 'https://play.clickhouse.com/?user=play&default_format=CSV' --data "SELECT 1 AS a, 'x' AS b" | head -3; echo "--- arrayJoin:"; timeout 20 curl -sS -m 15 'https://play.clickhouse.com/?user=play&default_format=JSONCompact' --data "SELECT arrayJoin([1,2,3]) AS n, n*n AS square" | head -8
tables 95
tables       95
columns      3886
parts        Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.parts. (ACCESS_DENIED) (version 26.10.3.7 (official build))
events       386
metrics      575
settings     1956
functions    2008
formats      116
numbers      Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.numbers. (ACCESS_DENIED) (version 26.10.3.7 (official build))
one          1
clusters     Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.clusters. (ACCESS_DENIED) (version 26.10.3.7 (official build))
processes    Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.processes. (ACCESS_DENIED) (version 26.10.3.7 (official build))
query_log    Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant SELECT ON system.query_log. (ACCESS_DENIED) (version 26.10.3.7 (official build))
--- format via url param:
1,"x"
--- arrayJoin:
{
	"meta":
	[
		{
			"name": "n",
			"type": "UInt8"
		},
		{
Thinking…

Next, rewrite items 2, 5, and 6 of http_api.sh using playground-compatible queries, and add a note about which ones require one's own instance.

Item 2: arrayJoin + default_format=JSONCompact via URL parameter? Should item 2 stay as a POST using FORMAT JSONCompact with arrayJoin?

Item 5: default_format=CSV via URL parameter (HTTP-level), plus a commented-out note showing a SETTINGS example for one's own instance.

Item 6: Observability of system tables using system.tables + system.events/metrics (permitted targets). Display the top largest tables by total_bytes and some metrics. On one's own instance, mention system.query_log / system.parts.

Alright, let's write it.

File edits

1 file

Completed
Modifiedclickhouse-demo/http_api.sh+20−14View changes

/sandbox/repo/clickhouse-demo/http_api.sh

Replacement excerpt

post "2. ask for JSON with a FORMAT clause (JSONCompact, JSONEachRow, CSV, Parquet...)" \
-"SELECT number AS n, number * number AS square
-FROM system.numbers
-LIMIT 5
+"SELECT arrayJoin([1, 2, 3, 4, 5]) AS n, n * n AS square
FORMAT JSONCompact"

Replacement excerpt

-post "5. server-side query settings travel as query-string args / SETTINGS clause" \
-"SELECT count() AS rows_read
+printf '\n== 5. output format can also be set as an HTTP query-string arg (default_format=CSV)\n\n'
+curl -sS --max-time 60 "${AUTH[@]}" "${URL}&default_format=CSV" --data-binary \
+"SELECT id, score, left(title, 40) AS title
FROM default.hackernews
-SETTINGS max_threads = 2, max_memory_usage = 1000000000
-FORMAT JSONCompact"
+WHERE score > 4000
+ORDER BY score DESC
+LIMIT 3"
+# On an instance you own (not readonly) you can also tune the server per query:
+# ... FROM default.hackernews SETTINGS max_threads = 2, max_memory_usage = 1000000000
-post "6. system tables are free observability (query_log, parts, merges, metrics)" \
-"SELECT query_duration_ms, read_rows, formatReadableSize(read_bytes) AS read, left(query, 60) AS query
-FROM system.query_log
-WHERE type = 'QueryFinish' AND query NOT LIKE '%query_log%'
-ORDER BY event_time DESC
-LIMIT 3
-FORMAT Vertical"
+post "6. system tables = free observability (here: what is on this server)" \
+"SELECT database, count() AS tables, formatReadableSize(sum(total_bytes)) AS on_disk,
+ sum(total_rows) AS rows
+FROM system.tables
+WHERE database NOT IN ('system', 'INFORMATION_SCHEMA', 'information_schema')
+GROUP BY database
+ORDER BY sum(total_bytes) DESC
+LIMIT 5
+FORMAT PrettyCompactMonoBlock"
+# On your own instance the interesting ones are system.query_log (slow queries),
+# system.parts (data layout), system.merges and system.metrics.
Thinking…

Wait: ${URL}&default_format=CSV — in the case of the playground, the URL already has ?user=play attached, so appending &default_format=CSV will work. However, for my own instance, the URL is https://host:port/ without a query string, so &default_format=CSV will be incorrect. Fix: conditionally construct FORMAT_PARAM.

Let me fix this with a variable: if the URL contains '?', then SEP='&'; otherwise SEP='?'.

File edits

1 file

Completed
Modifiedclickhouse-demo/http_api.sh+3−1View changes

/sandbox/repo/clickhouse-demo/http_api.sh

Replacement excerpt

if [[ "$HOST" == "play.clickhouse.com" ]]; then
URL="https://${HOST}:${PORT}/?user=${USER_NAME}"
AUTH=()
+ SEP="&" # URL already carries a query string
else
URL="https://${HOST}:${PORT}/"
AUTH=(-H "X-ClickHouse-User: ${USER_NAME}" -H "X-ClickHouse-Key: ${PASSWORD}")
+ SEP="?"
fi

Replacement excerpt

-curl -sS --max-time 60 "${AUTH[@]}" "${URL}&default_format=CSV" --data-binary \
+curl -sS --max-time 60 "${AUTH[@]}" "${URL}${SEP}default_format=CSV" --data-binary \
Thinking…

Also, regarding example.py: it uses system.tables for table_exists — accessible in the playground. Good.

Note: example.py's show() function has a weird conditional print expression (ternary operator precedence bug):

print(
    f"\n{len(rows)} rows | wall {wall_ms:.0f} ms | "
    f"server scanned {read_rows} rows / {int(read_bytes) / 1e6:.1f} MB"
    if str(read_bytes).isdigit()
    else f"\n{len(rows)} rows | wall {wall_ms:.0f} ms"
)

This works (the ternary applies to the concatenated string), but int(read_bytes) could fail if it were a float string like "435400000" — that's fine. It output correctly. Still, I'll clean it up for readability.

Also, client.server_tz printed UTC — good.

Alright, re-test http_api.sh in full, and write a README describing pricing and notes. Additionally, verify that example.py's show() still works fine.

First, let me refactor that print into clearer code.

File edits

1 file

Completed
Modifiedclickhouse-demo/example.py+8−8View changes

/sandbox/repo/clickhouse-demo/example.py

Replacement excerpt

+ # The server reports what each query actually cost - the single most useful
+ # thing for learning ClickHouse, because cost is driven by rows/bytes read.
summary = result.summary or {}
- read_rows = summary.get("read_rows", "n/a")
- read_bytes = summary.get("read_bytes", "n/a")
- print(
- f"\n{len(rows)} rows | wall {wall_ms:.0f} ms | "
- f"server scanned {read_rows} rows / {int(read_bytes) / 1e6:.1f} MB"
- if str(read_bytes).isdigit()
- else f"\n{len(rows)} rows | wall {wall_ms:.0f} ms"
- )
+ read_rows = str(summary.get("read_rows", ""))
+ read_bytes = str(summary.get("read_bytes", ""))
+ footer = f"\n{len(rows)} rows | wall {wall_ms:.0f} ms"
+ if read_rows.isdigit() and read_bytes.isdigit():
+ footer += f" | server scanned {int(read_rows):,} rows / {int(read_bytes) / 1e6:.1f} MB"
+ print(footer)
Thinking…

Next, let's write the README.md including the following:

  • Contents of the demo
  • How to run it (playground default / your own instance)
  • Sample output snippets
  • Pricing section (accurate, retrieved live)
  • Manageability notes
  • Known limitations (playground is read-only)

After that, run both scripts end-to-end to verify, then commit.

Pricing details to include (retrieved live from clickhouse.com/pricing, us-east-1, November 2025... wait, the current date is around November 8, 2026? The sandbox date shows November 8, and generate_rows outputted 2026-11-08. So "confirmed November 2026").

Rates:

  • Basic: $0.2181/unit/hour, storage $25.30/TB/month, up to 1TB storage, 8–12 GiB total memory
  • Scale: $0.2985/unit/hour
  • Enterprise: $0.3903/unit/hour
  • 1 compute unit = 2 vCPU + 8 GiB RAM, metered per minute, billed per CU-hour
  • Idle scales to zero (compute), storage billed monthly
  • Egress: to public internet $0.1152/GB, between regions $0.0312/GB
  • ClickPipes is separate
  • Trial: $300 in credits over 30 days
  • OSS: permanently free (Apache 2.0)

Example of cost calculation: leave one Basic instance (minimum 0.25 units?) running 24/7 — I don't know the minimum unit count. The calculator mentioned "minimum compute per replica". Don't fabricate: give as an example "if you run 1 compute unit for 730 hours, compute is about $159/month + 100GB storage is $2.53/month". 0.2181 × 730 = $159.2. Storage: $25.30/TB → 100 GB ≈ $2.53. Okay, present it as an arithmetic example with a note that the Basic tier's minimum compute is less than 1 unit and scales to zero when idle.

Also mention that the demo itself cost $0 (the playground is free).

The README should also document what was verified and what wasn't (the write path is only verified via EXPLAIN and has not been executed because there are no writable credentials).

Alright, let's write it.

File edits

1 file

Completed
Writtenclickhouse-demo/README.mdView changes

/sandbox/repo/clickhouse-demo/README.md

Written content · previous contents were not recorded

# ClickHouse demo — hosted only, no local server
A minimal, runnable ClickHouse example that talks to a **hosted** ClickHouse over
HTTPS. Nothing is installed or started locally except a Python package.
```
clickhouse-demo/
├── example.py # official Python SDK (clickhouse-connect): analytics + write lifecycle
├── http_api.sh # the same ideas over the raw HTTP interface with curl (zero deps)
├── requirements.txt # clickhouse-connect
└── README.md
```
## Run it
```bash
pip install -r requirements.txt # only clickhouse-connect (+ pandas if you want query_df)
python3 example.py # default: public playground, no credentials
./http_api.sh # same, with curl
```
Default target is ClickHouse's own hosted playground, `play.clickhouse.com`
(user `play`, no password, free). It carries real datasets — ~37M Hacker News
rows, ~43M cell towers, ClickBench, NOAA — so the analytics are genuine
multi-million-row aggregations that return in ~100 ms.
### Against your own instance (ClickHouse Cloud, or any reachable server)
```bash
CLICKHOUSE_HOST=<your>.<region>.aws.clickhouse.cloud \
CLICKHOUSE_USER=default \
CLICKHOUSE_PASSWORD=<password> \
python3 example.py
```
Self-hosted plain HTTP: `CLICKHOUSE_SECURE=false CLICKHOUSE_PORT=8123`.
## What it shows
**Read path** (`example.py` Part 1, runs anywhere the datasets exist):
| # | Idea | Query shape |
|---|------|-------------|
| 1a | Columnar aggregation | `GROUP BY` + `avg`/`max` over 37M rows |
| 1b | Time bucketing + quantiles | `toYear(time)`, `countIf`, `quantile(0.95)` |
| 1c | `Enum8` columns | second dataset, 43M rows |
| 1d | Bind parameters | `{min_score:Int32}` — values sent separately from SQL |
| 1e | pandas integration | `client.query_df()` returns a typed DataFrame |
Each result prints what the server reported it cost (`read_rows`, `read_bytes`),
which is the thing to watch in ClickHouse: cost tracks bytes read, not rows
returned. Example footer from a real run:
```
8 rows | wall 140 ms | server scanned 37,171,166 rows / 435.4 MB
```
**Write path** (`example.py` Part 2, needs DDL rights):
`CREATE DATABASE` → `CREATE TABLE` (MergeTree, monthly partitions, `ORDER BY`
primary key, `TTL`) → `client.insert()` of 5,000 generated rows (native columnar
insert, no SQL text) → aggregations → `ARRAY JOIN` → `Map` column access →
`system.tables` compression stats → `DROP TABLE` cleanup.
**HTTP API** (`http_api.sh`): output formats via `FORMAT` clause and the
`default_format` URL arg, query-string bind parameters (`param_min_score=...`),
auth headers (`X-ClickHouse-User` / `X-ClickHouse-Key`), `system` tables.
## Pricing
Verified against `clickhouse.com/pricing` (N. Virginia / us-east-1) in Nov 2026.
* **Open-source ClickHouse: free, forever** (Apache-2.0). Self-host and the only
cost is your hardware/ops. This demo's read path costs **$0** — the playground
is free.
* **ClickHouse Cloud (the hosted product): pure consumption pricing.** Compute and
storage are metered separately, and **compute scales to zero when idle**, so an
idle dev instance bills storage only.
* One **compute unit = 2 vCPUs + 8 GiB RAM**, metered per minute, billed per
compute-unit-hour:
| Plan | Compute | Storage | Notes |
|------|---------|---------|-------|
| Basic | **$0.2181**/unit/hr | $25.30 per 1 TB/mo | up to 1 TB storage, 8–12 GiB total memory, single replica |
| Scale | **$0.2985**/unit/hr | $25.30 per 1 TB/mo | 2+ AZs, unlimited storage, autoscaling, private networking |
| Enterprise | **$0.3903**/unit/hr | $25.30 per 1 TB/mo | CMEK, compliance, scheduled upgrades |
* Storage is $25.30 per TB-month ≈ **$0.0247/GB-month** (~$2.53 for 100 GB),
after ClickHouse's typical 5–10x compression.
* Egress: public internet from **$0.1152/GB**, inter-region from **$0.0312/GB**.
Ingest from ClickPipes is priced per connector type.
* Trial: every new organisation gets **$300 of credits over 30 days**, no card
charged until credits are gone. Change plan later without migration.
* Rough maths: one always-on Basic compute unit ≈ 0.2181 × 730 h ≈ **$159/mo**
compute + storage. A hobby project that idles most of the day costs a few
dollars; ClickHouse's own guidance is ~1:25 memory-to-storage for real-time
analytics, so size memory first.
## Verified vs. not
* **Ran green:** the whole read path of `example.py` and `http_api.sh` against
`play.clickhouse.com` (ClickHouse 26.10.3.7).
* **Not executed:** Part 2 (write lifecycle). The playground is hard read-only —
`CREATE DATABASE`, `CREATE TABLE` and even `CREATE TEMPORARY TABLE` all return
`ACCESS_DENIED (code 497)` for user `play`, and no ClickHouse Cloud credentials
were available in this environment. Every Part-2 statement was therefore
validated against the live server's parser with `EXPLAIN AST` / `EXPLAIN
SYNTAX` (all OK), and the insert payload was dry-run for column/type shape, but
it has not been executed end-to-end. Point the script at a real instance (the
$300 trial is enough) to run it.
* Also blocked on the playground: `system.numbers`, `system.query_log`,
`system.parts`, and changing settings such as `max_threads` (readonly mode).
`http_api.sh` sticks to what the playground allows and notes the alternatives.
## Notes for extending it
* One client object = one connection pool; create it once and reuse it.
* Use bind parameters (`{name:Type}` + `parameters=`), never f-strings, for values.
* Batch inserts (thousands of rows per `insert()`); ClickHouse hates many tiny
inserts — they create parts and force merges.
* `ORDER BY` is the primary key/skip index: put your most selective filter
columns first. `PARTITION BY` should be coarse (month), never high-cardinality.
* `LowCardinality(String)` for enums-ish columns, `Array`/`Map`/`Nested` instead
of join tables — ClickHouse prefers denormalised wide tables.
Thinking…

Next, I'll run a final end-to-end verification of both scripts. I'll check the exit codes and grep for error strings. I'll also run pyflakes/compile checks.

cd /sandbox/repo/clickhouse-demo && python3 -m py_compile example.py && echo "PY_COMPILE_OK"; timeout 240 python3 example.py > /tmp/py.out 2>/tmp/py.err; echo "PY_EXIT=$?"; grep -icE 'exception|error|traceback|denied' /tmp/py.out /tmp/py.err; echo "--- sections:"; grep -E '^(Part|host|server|mode)' /tmp/py.out | head -20; echo "--- stderr:"; cat /tmp/py.err
PY_COMPILE_OK
PY_COMPILE_OK
PY_EXIT=0
/tmp/py.out:1
/tmp/py.err:0
--- sections:
host     : play.clickhouse.com:443 (https)
mode     : read-only (public playground)
server   : ClickHouse 26.10.3.7 (UTC)
Part 1a: GROUP BY over ~37M rows (aggregation is ClickHouse's bread and butter)
Part 1b: time bucketing + quantiles (quantile is approximate and very fast)
Part 1c: Enum8 column, second dataset (~43M rows)
Part 1d: parameterised query (bind variables, not string building)
Part 1e: client.query_df() -> pandas DataFrame
Part 2: write lifecycle
--- stderr:
cd /sandbox/repo/clickhouse-demo && bash -n http_api.sh && echo "BASH_SYNTAX_OK"; timeout 240 ./http_api.sh > /tmp/sh.out 2>/tmp/sh.err; echo "SH_EXIT=$?"; grep -cE 'Code: [0-9]+' /tmp/sh.out; echo "--- error lines (only item 7 expected):"; grep -nE 'Code: [0-9]+' /tmp/sh.out
BASH_SYNTAX_OK
BASH_SYNTAX_OK
SH_EXIT=0
1
--- error lines (only item 7 expected):
105:Code: 497. DB::Exception: play: Not enough privileges. To execute this query, it's necessary to have the grant CREATE TABLE ON default.nope_from_demo. (ACCESS_DENIED) (version 26.10.3.7 (official build))
Thinking…

Both scripts completed successfully (exit 0). The only "error" matches are as follows:

  • py.out: 1 hit — this is the "ACCESS_DENIED, code 497" mentioned in the explanatory text of Part 2, which is intentional.
  • sh.out: 1 hit on line 105 — this is an intentionally demonstrated write rejection.

I'll re-check the entire shell output to confirm that items 2, 5, and 6 are producing the correct results.

read /tmp/sh.out{"content":[{"text":"\t\t\"elapsed\": 0.001228642,\n\t\t\"rows_read\": 1,\n\t\t\"bytes_read\": 1\n\t}\n}\n\n== 3. aggregation over a hosted dataset (~37M rows)\…ctrl+o
{"content":[{"text":"\t\t\"elapsed\": 0.001228642,\n\t\t\"rows_read\": 1,\n\t\t\"bytes_read\": 1\n\t}\n}\n\n== 3. aggregation over a hosted dataset (~37M rows)\n\nSELECT toYear(time) AS year, count() AS items, round(avg(score), 1) AS avg_score\nFROM default.hackernews\nWHERE time >= toDateTime('2019-01-01')\nGROUP BY year\nORDER BY year DESC\nLIMIT 6\nFORMAT PrettyCompactMonoBlock\n\n   ┌─year─┬───items─┬─avg_score─┐\n1. │ 2023 │ 2969111 │       1.5 │\n2. │ 2022 │ 4448191 │       1.5 │\n3. │ 2021 │ 4155060 │       1.5 │\n4. │ 2020 │ 3673138 │       1.7 │\n5. │ 2019 │ 3127712 │       1.8 │\n   └──────┴─────────┴───────────┘\n\n== 4. bind parameters on the query string (never interpolate user input into SQL)\n\nSELECT id, score, left(title, 50) AS title\nFROM default.hackernews\nWHERE type = 'story' AND score >= {min_score:Int32}\nORDER BY score DESC\nLIMIT 3\nFORMAT TSVWithNames\n\nid\tscore\ttitle\n16582136\t6015\tStephen Hawking has died\n11116274\t5771\tA Message to Our Customers\n3078128\t4338\tSteve Jobs has passed away.\n\n== 5. output format can also be set as an HTTP query-string arg (default_format=CSV)\n\n16582136,6015,\"Stephen Hawking has died\"\n11116274,5771,\"A Message to Our Customers\"\n3078128,4338,\"Steve Jobs has passed away.\"\n\n== 6. system tables = free observability (here: what is on this server)\n\nSELECT database, count() AS tables, formatReadableSize(sum(total_bytes)) AS on_disk,\n        sum(total_rows) AS rows\nFROM system.tables\nWHERE database NOT IN ('system', 'INFORMATION_SCHEMA', 'information_schema')\nGROUP BY database\nORDER BY sum(total_bytes) DESC\nLIMIT 5\nFORMAT PrettyCompactMonoBlock\n\n   ┌─database───────┬─tables─┬─on_disk────┬────────rows─┐\n1. │ default        │     69 │ 911.59 GiB │ 19393357697 │ -- 19.39 billion\n2. │ blogs          │      9 │ 141.35 GiB │ 17785193865 │ -- 17.79 billion\n3. │ mgbench        │      3 │ 2.66 GiB   │   206729016 │ -- 206.73 million\n4. │ git_clickhouse │      3 │ 260.33 MiB │     7863993 │ -- 7.86 million\n\n[10 more lines in file. Use offset=100 to continue.]","type":"text"}]}
Thinking…

Everything is working. Next, commit the work and tidy up the temp files (pricing.html in /tmp is outside the repo so it's fine). Check git status and commit.

Also, pycache from py_compile — .gitignore covers pycache/. Let me check git status.

cd /sandbox/repo && git status --short && git add clickhouse-demo && git -c user.name=pi -c user.email=pi@local commit -q -m "Add hosted-only ClickHouse demo: SDK example, raw HTTP API script, pricing notes" && git log --oneline -2 && echo "--- files:" && ls -la clickhouse-demo
?? clickhouse-demo/
?? clickhouse-demo/
a1b36bd Add hosted-only ClickHouse demo: SDK example, raw HTTP API script, pricing notes
9ebf5fb Initial workspace
--- files:
total 44
drwx------ 3 root root  4096 Nov  8 02:12 .
drwxr-xr-x 4 root root  4096 Nov  8 02:10 ..
-rw------- 1 root root  6000 Nov  8 02:12 README.md
drwx------ 2 root root  4096 Nov  8 02:12 __pycache__
-rwx------ 1 root root 15143 Nov  8 02:11 example.py
-rwx------ 1 root root  3500 Nov  8 02:11 http_api.sh
-rw------- 1 root root    26 Nov  8 02:11 requirements.txt
Thinking…

Committed (pycache is ignored by .gitignore — confirmed: the fact that only clickhouse-demo/ was added means pycache is excluded. Since git status showed everything as "?? clickhouse-demo/", let me verify that pycache isn't committed).

Quick check: git show --stat. Then done — write the final answer covering the following:

  1. What was built and how to run it
  2. How the pricing structure works
  3. Whether it's easy for me to manage (assessment of the developer workflow)
  4. Blockers: playground is read-only → no credentials, so the write path wasn't executed.
cd /sandbox/repo && git show --stat --oneline HEAD | head -10
a1b36bd Add hosted-only ClickHouse demo: SDK example, raw HTTP API script, pricing notes
a1b36bd Add hosted-only ClickHouse demo: SDK example, raw HTTP API script, pricing notes
 clickhouse-demo/README.md        | 122 +++++++++++
 clickhouse-demo/example.py       | 451 +++++++++++++++++++++++++++++++++++++++
 clickhouse-demo/http_api.sh      |  93 ++++++++
 clickhouse-demo/requirements.txt |   1 +
 4 files changed, 667 insertions(+)
Thinking…

Wait a sec — example.py is 451 lines? What I wrote should have been about 430 lines... whatever, fine.

Now, on to the final answer. Keep it concise but cover the following:

  • What was built + how to run it + evidence it actually works
  • How the pricing structure works
  • Manageability judgment (can it be easily managed)
  • Blockers (being honest): playground is read-only → Part 2 was not executed; there is a developer workflow so I didn't stop.

Built and verified against a real hosted ClickHouse — no local stack, nothing long-running.

What's there — /sandbox/repo/clickhouse-demo/ (committed, a1b36bd)

FileWhat it does
example.pyOfficial Python SDK (clickhouse-connect) — analytics + full write lifecycle
http_api.shSame ideas over ClickHouse's raw HTTP interface with curl (zero deps)
requirements.txtclickhouse-connect
README.mdRun instructions, pricing, verified-vs-not notes
pip install -r requirements.txt
python3 example.py     # default target: play.clickhouse.com (free, no credentials)
./http_api.sh

Ran green, exit 0: read path against the hosted playground (ClickHouse 26.10.3.7) — real multi-million-row aggregations: GROUP BY over 37M Hacker News rows (140 ms, 37,171,166 rows scanned), quantile(0.95) time bucketing, Enum8 over 43M cell towers, bind parameters ({min_score:Int32}), and client.query_df() → typed pandas DataFrame. http_api.sh demos FORMAT/default_format, query-string bind params, auth headers, system tables.

Pricing (pulled live from clickhouse.com/pricing, us-east-1)

  • OSS ClickHouse: free forever (Apache-2.0). This demo's read path cost $0.
  • ClickHouse Cloud: pure consumption. Compute and storage metered separately; compute scales to zero when idle, so an idle instance bills storage only.
  • 1 compute unit = 2 vCPU + 8 GiB RAM, metered per minute, billed per unit-hour: Basic $0.2181, Scale $0.2985, Enterprise $0.3903. Storage $25.30/TB-month (~$0.0247/GB, ~$2.53 per 100 GB) on all tiers; Basic caps at 1 TB / 8–12 GiB memory.
  • Egress from $0.1152/GB (internet), $0.0312/GB (inter-region); ClickPipes priced per connector.
  • Trial: $300 credits / 30 days, card not charged until credits run out.
  • Ballpark: one always-on Basic unit ≈ 0.2181 × 730 h ≈ $159/mo + storage; a demo that idles costs a few dollars.

Is it easy for me to manage? Yes

Fully actionable developer workflow: plain SQL over HTTPS, one pip package, no daemons, no ports, no Docker. Errors are precise (code + required grant), system.* tables give free observability, and each query reports read_rows/read_bytes so I can reason about cost directly. Two sharp edges worth knowing: DDL perms are strict (playground denies everything, code 497), and readonly connections reject setting changes like max_threads.

Where I got blocked (one thing, worked around)

play.clickhouse.com is hard read-only — CREATE DATABASE, CREATE TABLE, even CREATE TEMPORARY TABLE return ACCESS_DENIED (497), and no ClickHouse Cloud credentials exist in this environment. So Part 2 (CREATE → INSERT 5,000 rows → aggregate → ARRAY JOIN → Map access → DROP) is written but never executed. I validated every statement against the live server's parser via EXPLAIN AST/EXPLAIN SYNTAX (all OK) and dry-ran the insert payload for column/type shape. To actually run it, give me a host/user/password (the $300 trial suffices):

CLICKHOUSE_HOST=<host> CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD=<pw> python3 example.py

http_api.sh also avoids what the playground blocks (system.numbers, system.query_log, system.parts, settings changes) and notes the equivalents for a real instance.

AX Fundamentals

23 checks. Open an area for details.

This check is a quick preview. Test more tasks and models with Gauge Agents.

Test your product with Gauge Agents

AX Check by Gauge