Learning Objectives
- Explain, step by step, what ClickHouse does between receiving an INSERT statement and that data becoming queryable
- Explain why a table's input format (CSV, JSONEachRow, Native, Parquet) is independent of how ClickHouse stores that data on disk
- Predict, given a described insert pattern, what happens to the number of parts a table accumulates
- Explain why ClickHouse strongly favors large, infrequent inserts over many small ones
Why This Matters
The moment you understand that every INSERT statement becomes its own immutable file on disk, a whole category of ClickHouse operational pain stops being mysterious. The "too many parts" error that stalls an ingestion pipeline, the standard advice to batch writes client-side instead of firing them one row at a time, the reason a naive port of an OLTP app's insert pattern can grind a ClickHouse table into merge-pressure trouble — all of it follows directly from one mechanical fact this lesson gives you: what an INSERT actually does. Once you have that model, those symptoms have an obvious cause instead of looking like the database being fussy for no reason.
The problem an INSERT has to solve
MergeTree — the table engine nearly every ClickHouse table you'll encounter is built on — wants its data sorted and laid out efficiently on disk, so that scans and range lookups are fast; that's the whole promise of the engine. But data doesn't arrive pre-sorted and pre-compacted. It arrives constantly, in small, messy increments, from applications that have no idea what order the table wants its rows in.
Something has to reconcile those two facts: "reads want one big, sorted, tidy structure" and "writes show up as small, unsorted dribbles, all the time." Re-sorting and rewriting the entire table on every single INSERT would make writes catastrophically slow as the table grew — the exact opposite of what a database needs to do. So MergeTree takes a different approach: instead of merging new data into one giant structure immediately, every INSERT gets its own small, self-contained, already-sorted-internally file, written once and never modified again. A separate background process reconciles those files into fewer, larger ones later, on its own schedule, purely as later housekeeping — not something a query has to wait for.
That small, self-contained, immutable file is called a part. Understanding INSERT means understanding how a part gets made.
What happens, in order
When ClickHouse receives an INSERT statement, it does the following, in this order, before returning control to the client:
- Parse and decode. ClickHouse reads the incoming data according to whatever format it was sent in (more on formats below) and decodes it into an in-memory block of columns — the same columnar shape the table will eventually store it in.
- Sort the block. The rows in that block are sorted according to the table's
ORDER BYexpression. This happens on every INSERT, to every batch, regardless of what order the rows arrived in. (WhyORDER BYis the thing being sorted by, and what it costs to get this wrong, is its own lesson later in the course — for now, just note that sorting happens here, per batch.) - Write a new part. The sorted block is written to a brand-new directory on disk — one column file per column, plus an index file — with a name unique to that INSERT. Nothing existing is modified. This directory is the part.
- Register and expose it. As soon as the part's files are written, it's immediately visible to any query that runs against the table. There is no waiting period, and no synchronous merge step blocking the INSERT from returning.
That's the entire lifecycle for one INSERT. Nothing else happens synchronously. The part just sits there, on disk, as one of potentially many, until a background merge process — running independently, on its own schedule — later decides to combine it with other parts into something larger. That merge is an optimization for future reads, not a requirement for the data to be correct or queryable right now.
Formats: how rows travel, not how they're stored
Every INSERT specifies, explicitly or by default, a format — the scheme ClickHouse uses to decode the incoming bytes into rows and columns. The format only describes step 1 above, the parsing step. It has no bearing on steps 2 through 4: every format gets sorted the same way, by the same ORDER BY expression, and lands in the same kind of column-file part on disk. Two INSERTs carrying identical rows, one sent as CSV and one sent as JSON, produce parts that are indistinguishable from each other once written — same columns, same sort order, same file layout.
ClickHouse supports dozens of formats, but four come up constantly:
INSERT INTO events (user_id, event_type, event_time) VALUES
(1001, 'page_view', '2024-01-15 10:22:31'),
(1002, 'click', '2024-01-15 10:22:33');
INSERT INTO events FORMAT CSV
1001,page_view,"2024-01-15 10:22:31"
1002,click,"2024-01-15 10:22:33"
CSV is the obvious choice when data is already sitting in spreadsheets or exports from other systems that speak CSV natively.
INSERT INTO events FORMAT JSONEachRow
{"user_id": 1001, "event_type": "page_view", "event_time": "2024-01-15 10:22:31"}
{"user_id": 1002, "event_type": "click", "event_time": "2024-01-15 10:22:33"}
JSONEachRow is the natural fit for application code that already produces one JSON object per event — no CSV-escaping logic required, and no wrapping array that would force the sender to buffer the whole payload before it knows where the array closes.
INSERT INTO events
SELECT * FROM file('historical_events.parquet', 'Parquet');
Parquet is a columnar file format used widely across the data-lake and analytics-tooling world (Spark, pandas, and most warehouse export tools all read and write it). Loading Parquet directly — instead of exporting it to CSV first and re-parsing text — skips a wasteful round trip through an untyped intermediate format: Parquet already carries per-column types and a columnar physical layout, which is close to what MergeTree wants anyway.
Native is different in kind from the other three: it's ClickHouse's own binary wire format, the one clickhouse-client uses by default when talking to a server, and the fastest option precisely because there's no text parsing or per-row object overhead — the bytes on the wire are already close to the columnar block ClickHouse will sort and write. It's also the format ClickHouse itself uses for interaction between servers, not just between a client and a server. You won't typically hand-write Native data; it shows up as a --format Native flag or as the format two ClickHouse processes use to exchange data with each other efficiently.
The takeaway: pick a format based on what's convenient for whatever is producing the data. It changes nothing about how the table stores or sorts the result.
Common Misconception
"ClickHouse is fast, so I can INSERT one row at a time, thousands of times a second, the same way I would with a transactional database."
This is the single most common way to make a MergeTree table miserable. Speed at query time says nothing about the cost of this insert pattern, because the mechanism above doesn't care how fast ClickHouse is at scanning columns — it cares how many parts get created. One row-at-a-time INSERT creates one part per call, no matter how small that part is. Send 10,000 single-row inserts and you get 10,000 tiny parts, all waiting on the same background merge process to clean them up, competing with each other for the same limited merge capacity. Send one INSERT with 10,000 rows instead, and you get exactly one part, already sorted, with nothing left for the background process to urgently do. The fix isn't a faster server — it's batching the writes before they ever reach ClickHouse, so each INSERT carries as many rows as the application can reasonably accumulate.
Predict, Then Verify
Two teams load the same 1,000,000 rows of event data into identical MergeTree tables over the course of an hour.
- Team A batches events client-side and sends one INSERT every 10 seconds, each carrying roughly 2,800 rows.
- Team B sends one INSERT per event, as each event happens — one row per call, spread across the same hour.
Predict: at the end of the hour, before any background merges have had a chance to fully catch up, which team's table has accumulated dramatically more parts — and why does that gap exist even though both tables now contain the exact same 1,000,000 rows?
Reveal the answer →
Team B's table has accumulated dramatically more parts — roughly 1,000,000 of them, one per single-row INSERT, versus roughly 360 for Team A (one part per 10-second batch over an hour). The row content is identical either way, but part count is driven entirely by how many separate INSERT calls were made, not by how many rows ended up on disk. Every INSERT creates exactly one new part regardless of its size, so the same data volume split across far more, far smaller INSERT calls produces far more parts — and far more background-merge work needed just to get back to a reasonable number of files.
Summary
An INSERT statement's job is to turn whatever format the data arrived in — VALUES, CSV, JSONEachRow, Parquet, or ClickHouse's own binary Native format — into a sorted, columnar block and write it to disk as a new, immutable part; the format only controls the parsing step, and has no effect on how the result is sorted or stored. That part is queryable the instant it's written, with no synchronous merge in the critical path; combining parts into fewer, larger ones is a separate, independent background process that runs later, purely for read efficiency. Because every INSERT produces exactly one part no matter how many rows it carries, the number of INSERT calls — not the total row count — is what determines how many parts a table accumulates, which is why ClickHouse strongly rewards batching writes into fewer, larger INSERTs rather than sending rows one at a time.
Quiz
1. One INSERT statement carrying 5,000 rows completes. What does that create on disk?
2. A table's ORDER BY is (event_time, user_id). The same three rows are inserted once via CSV and once via JSONEachRow, as two separate INSERT statements. What will differ between the two resulting parts?
3. Why does sending 10,000 individual single-row INSERT statements cause more operational trouble than sending one INSERT with 10,000 rows, even though the total data written is identical?
4. True or false: a query can only read rows from a freshly completed INSERT after the background merge process has combined that data into a larger part.
5. A team has historical event data sitting in Parquet files from an old Spark pipeline. Why might loading it directly via the Parquet format be a better choice than converting it to CSV first and inserting that?