ClickHouse Fundamentals

Column-oriented storage, creating databases and tables, core data types, and reading and writing data in ClickHouse.

This article covers enough ClickHouse to create a database, define a table, load some rows, and read them back. It also introduces the one idea that surprises people coming from other databases: in ClickHouse, ORDER BY is part of how a table is stored, not just how a result is sorted.

Mental model#

Text
Client (HTTP :8123 or native :9000)DatabaseTable  ─  engine decides how rows are storedColumns on disk, compressed, sorted by the table's ORDER BY

A ClickHouse server holds databases. Each database holds tables. Each table has an engine that decides how its data is physically stored and maintained. For analytical tables the engine is almost always MergeTree or one of its variants.

Column-oriented storage#

ClickHouse stores each column separately and compressed. A query reads only the columns it names, so selecting three columns from a hundred-column table reads roughly three columns' worth of data.

Two consequences follow from this:

  • SELECT * on a wide table is expensive; name the columns you need.
  • Adding a rarely used column to a table is cheap, because queries that ignore it never read it.

Creating a database and a table#

Create a database with CREATE DATABASE:

SQL
CREATE DATABASE IF NOT EXISTS analytics;

Create a table with CREATE TABLE, listing each column with its type, choosing an engine, and giving an ORDER BY:

SQL
CREATE TABLE analytics.orders(    order_id     UInt64,    customer_id  UInt64,    created_at   DateTime,    amount       Float64,    status       String)ENGINE = MergeTreeORDER BY (created_at, order_id);

IF NOT EXISTS makes the statement safe to run again:

SQL
CREATE TABLE IF NOT EXISTS analytics.orders(    order_id     UInt64,    customer_id  UInt64,    created_at   DateTime,    amount       Float64,    status       String)ENGINE = MergeTreeORDER BY (created_at, order_id);

ORDER BY here is not about sorting results

In a MergeTree table, ORDER BY (created_at, order_id) tells ClickHouse to store the rows physically sorted by those columns and to build its primary index on them. It is a storage decision that affects every future query. It is not the same as writing ORDER BY at the end of a SELECT, which only sorts that one result. Choosing this key well is covered in ClickHouse MergeTree and Table Design.

Core data types#

ClickHouse types are explicit about size and signedness.

TypeUse for
UInt8 UInt16 UInt32 UInt64Non-negative integers; pick the smallest that fits
Int32 Int64Integers that can be negative
Float32 Float64Approximate decimals
Decimal(P, S)Exact decimals, such as money
StringText of any length
DateA calendar date
DateTimeA timestamp at one-second resolution
DateTime64(3)A timestamp with fractional seconds (3 = milliseconds)
Nullable(T)A column of type T that may also be NULL
LowCardinality(String)A string column with relatively few distinct values

A column can have a DEFAULT expression that fills in a value when an insert omits it:

SQL
CREATE TABLE analytics.events(    event_id   UInt64,    user_id    UInt64,    happened_at DateTime DEFAULT now())ENGINE = MergeTreeORDER BY (happened_at, event_id);

Prefer non-nullable columns where the data allows it; Nullable adds storage and query overhead. Use the smallest integer type that will always fit the range of the data.

Inserting data#

Insert literal rows with INSERT INTO ... VALUES:

SQL
INSERT INTO analytics.orders (order_id, customer_id, created_at, amount, status)VALUES    (101, 1, '2026-01-04 09:12:00', 1200, 'completed'),    (102, 1, '2026-01-04 10:40:00', 850,  'completed'),    (103, 2, '2026-01-05 14:05:00', 640,  'pending');

Insert the result of a query with INSERT INTO ... SELECT. This is how one table is built from another and is the backbone of building marts:

SQL
INSERT INTO analytics.completed_ordersSELECT order_id, customer_id, created_at, amountFROM analytics.ordersWHERE status = 'completed';

ClickHouse is built for inserts that arrive in batches. Many single-row inserts create many tiny storage parts and force extra background work. Insert thousands of rows at a time where possible.

Reading data#

A basic read names its columns, its table, and an optional filter:

SQL
SELECT order_id, amountFROM analytics.ordersWHERE status = 'completed'LIMIT 10;

LIMIT caps the number of returned rows and is useful while exploring a table. The full set of query clauses is covered in SQL Querying and Aggregations.

Where ClickHouse fits#

ClickHouse is a strong choice when:

  • queries scan and aggregate large tables
  • data is written in batches and rarely changed afterward
  • you need history and fast reporting over it

It is the wrong choice when:

  • you need frequent single-row updates or deletes
  • you need enforced foreign keys and transactions across tables
  • the main access pattern is fetching individual records by id

For those needs, an OLTP database such as PostgreSQL is the right tool, and a pipeline loads its data into ClickHouse for analysis.

Common mistakes#

Treating ORDER BY in CREATE TABLE as cosmetic#

It defines physical storage order and the primary index. Getting it wrong makes later queries slow in ways that are hard to fix without rebuilding the table.

Using SELECT * on wide tables#

It reads every column. Name only the columns the query needs.

Many small inserts#

Each insert creates a storage part. Batch rows before inserting.

Reaching for Nullable by default#

Nullable has a cost. Use a sensible default value or a non-nullable type when the data permits it.

Oversized integer types#

UInt64 everywhere wastes space and compresses worse. Choose the smallest type that covers the real range.

Quick reference#

TaskStatement
Create a databaseCREATE DATABASE IF NOT EXISTS analytics;
Create a tableCREATE TABLE analytics.t (...) ENGINE = MergeTree ORDER BY (...);
Insert literal rowsINSERT INTO analytics.t (...) VALUES (...);
Insert from a queryINSERT INTO analytics.t SELECT ... FROM ...;
Read some rowsSELECT a, b FROM analytics.t WHERE ... LIMIT 10;
Default valuecol DateTime DEFAULT now()

See also#