skip to content

What does ClickHouse's async_insert setting change about the INSERT write path?

level: middleimportance: should knowfreq 58%

answer

  1. the server does the batching instead of the client
  2. many small writes collapse into one part
  3. one knob decides when the client is told 'done'
  4. returning early buys speed and risks a loss window
  5. rows are not queryable until the flush

basics

~20 s

With async_insert enabled, the server collects rows from many small concurrent inserts into an in-memory buffer per table and shape, then flushes one part when the buffer hits a size or time limit. It turns server-side batching on for clients that cannot batch themselves.

solid answer

~50 s

Normally each INSERT writes its own part immediately. With `async_insert = 1` the server instead appends the incoming data to an in-memory buffer keyed by the table and the insert's shape, and writes a single part when the buffer exceeds `async_insert_max_data_size` or the busy timeout expires. Many small concurrent inserts therefore collapse into one part instead of hundreds. The durability question is `wait_for_async_insert`: left at its default the client's INSERT returns only after the buffer has actually been flushed to a part, so an acknowledgement means the data is durable; set to 0 the insert returns as soon as the rows are buffered, which is much faster but loses data if the server dies before the flush. Use it when you have many independent producers that cannot coordinate a batch; genuine client-side batching is still cheaper when it is available.

code

sql · 9 lines
sql
-- durable: client waits until the buffer is flushed to a part
INSERT INTO events
SETTINGS async_insert = 1, wait_for_async_insert = 1
VALUES (now(), 42, 'click');

-- fire-and-forget: lowest latency, explicit loss window on crash
INSERT INTO events
SETTINGS async_insert = 1, wait_for_async_insert = 0
VALUES (now(), 42, 'click');

go deeper

for a junior

Know that ClickHouse can batch small inserts server-side with async_insert instead of writing a part per statement, and that this is the answer when clients cannot batch themselves.

for a middle

Explain the buffer keyed by insert shape, the size and timeout flush triggers, and precisely what wait_for_async_insert changes about when the client is acknowledged.

for a senior

Show the operational judgment: the data-loss window at wait_for_async_insert=0, where errors surface, the visibility lag, and how deduplication tokens cover retries into a shared buffer.

for a principal

Decide where batching belongs in the architecture at all — producer-side, server-side buffering, or a streaming tier — and set the durability policy per data class rather than one global setting.

## The problem it solves ClickHouse wants large, infrequent inserts because every insert block becomes a data part and parts must be merged in the background. But plenty of real architectures cannot batch: hundreds of stateless application pods, each handling one request, each with a couple of rows to write. Coordinating a shared batch across them means building a queue, and a queue is a service to operate. Asynchronous inserts move that batching into the server. `async_insert = 1` tells ClickHouse: do not write a part for my rows now, add them to a buffer and write one part for everyone. ## How the buffer works The server maintains in-memory queues keyed by the *shape* of the insert — the target table, the column list, the format, and the relevant settings. Two inserts of the same shape share a queue; two inserts with different column lists do not. A queue is flushed into a single new part when either of two limits trips: - **Size** — the accumulated data exceeds `async_insert_max_data_size` (there is also a bound on the number of queries collected, `async_insert_max_query_number`). - **Time** — the busy timeout expires. Recent versions use an adaptive timeout that shortens under heavy load and lengthens when the stream is sparse, tuned through the busy-timeout settings rather than one fixed interval. On older versions the single `async_insert_busy_timeout_ms` governs it. The net effect on a table receiving 2,000 tiny inserts per second is dramatic: instead of 2,000 parts per second, you get a handful, and the merge pool stops losing the race that produces `Too many parts`. ## The durability knob you must understand `wait_for_async_insert` is the setting an interviewer will probe: - **`1` (default)** — the client's INSERT does not return until the buffer containing its rows has been flushed and the part written. Latency for any one insert rises to roughly the flush interval, but a successful acknowledgement genuinely means the data is on disk. This is the safe default and it is still a huge win, because the *throughput* limit was never per-insert latency, it was parts per second. - **`0`** — the INSERT returns as soon as the rows are copied into the in-memory buffer. Lowest latency, highest throughput, and an explicit data-loss window: if the server crashes or is killed before the flush, acknowledged rows are gone. Choose it only for telemetry-shaped data where losing a second of events is acceptable, and never for anything you would have to reconcile. ```sql INSERT INTO events SETTINGS async_insert = 1, wait_for_async_insert = 1 VALUES (now(), 42, 'click'); ``` The settings can also be set per user, per profile, or on the session, which is usually how it is rolled out — the application code does not change at all. ## Observability `system.asynchronous_insert_log` records each buffered insert and the flush it landed in, including status and any error. It is the table to reach for when someone asks why an insert seemed to succeed but the rows are not visible yet, or when you want to see how many queries each flush is collapsing. `system.parts` still tells you whether the part-per-second rate actually dropped after enabling the feature. ## Trade-offs and failure modes - **Visibility lag.** Rows are not queryable until the buffer flushes. A read-your-own-write test right after an insert can legitimately return nothing. This surprises people more than any other aspect. - **Errors move.** With `wait_for_async_insert = 0`, a parse error or a schema mismatch surfaces in the log and not to the caller. The client saw success. - **Memory.** Buffers live in server memory, and many distinct insert shapes mean many buffers. Uniform insert statements across your fleet keep the buffer count small. - **Deduplication.** Because rows from different clients are combined, per-block deduplication does not map cleanly onto a single client's retry. If retries must not duplicate, supply an `insert_deduplication_token` so identical retried content is recognised, or design the target table (for example a ReplacingMergeTree keyed appropriately) to tolerate duplicates. - **It is not a substitute for real batching.** If the producer *can* accumulate 100k rows and send one statement, that is still cheaper — no server memory, no visibility lag, no shared-buffer semantics. Async inserts are for when it genuinely cannot. ## The comparison an interviewer wants Asynchronous inserts and the older `Buffer` table engine solve the same problem differently. A `Buffer` table is a separate object you insert into that periodically flushes to a target table, and its data is invisible to some operations and lost on an unclean shutdown. Async inserts are a per-insert setting on the real table with an explicit durability switch and their own log. For new systems the setting is the modern answer; the Buffer engine survives mostly in older deployments.

  • With async_insert enabled, why might a SELECT right after a successful INSERT return no rows?
    Because the rows are still in the server's in-memory buffer and no part has been written yet. If `wait_for_async_insert` is 0 the acknowledgement only means 'buffered', so the visibility lag is up to the flush interval. Even with the default of 1 the insert waits for the flush, so this surprise is specific to the fire-and-forget mode.
  • When is client-side batching still better than async inserts?
    Whenever the producer can genuinely accumulate a large batch — a Spark job, an ETL task, a consumer that already reads in chunks. Client batching costs no server memory, has no visibility lag, gives per-batch error reporting, and keeps deduplication semantics tied to one caller. Async inserts exist for the case where many independent, stateless producers cannot coordinate a batch.
  • How does async_insert interact with retrying a failed insert?
    Rows from different callers are merged into a shared block, so the usual per-block deduplication does not cleanly cover one client's retry. Pass an `insert_deduplication_token` so identical retried content is recognised, or make the target table tolerant of duplicates. Assuming retries are automatically idempotent here is a common and expensive mistake.

saying these in an interview costs you the question

  • Thinks async_insert makes every insert fire-and-forget by default
  • Assumes buffered rows are immediately visible to SELECT
  • Treats it as a replacement for choosing a sane partition key
  • Believes it removes all duplicate risk on client retries
  • Enables wait_for_async_insert=0 for financial or billing data

context