Asynchronous inserts explained
How async_insert batches many small INSERTs on the server, what wait_for_async_insert really trades, and which settings to leave alone.
Part 1 explained that every INSERT creates a part and that many tiny inserts are the classic way to hurt a ClickHouse cluster. The best fix is to batch in the client. When that is not possible (many independent producers, serverless functions, IoT devices, a legacy application), ClickHouse can batch for you: asynchronous inserts.
The examples in this article need a running server, because asynchronous inserts rely on the server’s insert queue. Statements were parsed with clickhouse format on 26.4.
What it does
With async_insert = 1, the server does not write a part per statement. Instead it appends each insert’s data to an in-memory buffer keyed by table, format and settings. When the buffer is flushed, all the data in it is written as a single part. Inserts from many clients with the same query shape end up in the same buffer.
INSERT INTO events SETTINGS async_insert = 1
VALUES (now(), 42, 'click', '/pricing', 120, 'DE');
Note that the settings go before VALUES (or before SELECT). Settings placed after the data are not parsed as settings.
In recent releases (26.x, where this was checked) async_insert is enabled by default for every session, while older versions defaulted to 0. Check SELECT name, value FROM system.settings WHERE name LIKE 'async_insert%' on your version, and set it explicitly in client code so you do not depend on the default.
A little history
Asynchronous inserts arrived in ClickHouse 21.11, in late 2021, as an opt-in per query
(async_insert = 1), with wait_for_async_insert choosing whether the client waits for the flush.
Early adopters tried them from client drivers almost immediately; a 2022 proof of concept in Go
(ch-async-insert-poc) sent 100,000 single-row
inserts through clickhouse-go’s AsyncInsert with the wait flag off, against the same loop using
plain synchronous inserts. Today async_insert is on by default, which turns that once-experimental
pattern into the normal path for small writes.
When the buffer is flushed
A buffer is flushed when any of these happens first:
| Trigger | Setting |
|---|---|
| The collected data reaches a size limit | async_insert_max_data_size (10 MiB by default in 26.4) |
| A timer expires after the first data arrived | async_insert_busy_timeout_max_ms (200 ms by default; adaptive in recent versions, with a lower bound async_insert_busy_timeout_min_ms) |
| A number of insert queries is collected | async_insert_max_query_number (450; only with async_insert_deduplicate) |
The defaults differ in ClickHouse Cloud, which uses larger values. The practical consequence: a lone insert waits up to the timeout before it reaches disk. A flush every 200 ms means up to five parts per second per buffer instead of hundreds.
wait_for_async_insert: the important choice
wait_for_async_insert decides what the client is told and when.
| Value | Behaviour | Trade-off |
|---|---|---|
1 (default) |
The server answers only after the buffer holding your data has been flushed and written. | Latency is the flush interval. If the data is bad or the write fails, you get the error. |
0 |
The server answers as soon as it has put the data in the buffer (“fire and forget”). | Very fast acknowledgement, but if parsing or the write fails later, the client never learns about it. A server crash loses the buffer. |
So the question is not “async or not” but “do you need to know”. Use wait_for_async_insert = 1 for anything you cannot lose or re-send. Use 0 only for data you can afford to drop, such as best-effort telemetry.
With wait_for_async_insert = 1, a client that sends one insert at a time and waits for each answer is limited to roughly one insert per flush interval. Five sequential inserts at a 200 ms flush take about a second. The way to get throughput is concurrency, many connections or in-flight requests sharing the buffer, not a faster loop. Parallelism turns the wait into a shared one: all of them are acknowledged by the same flush.
What a client should look like
# many concurrent workers, each doing small inserts, all sharing server-side batches
client.command(
"INSERT INTO events SETTINGS async_insert = 1, wait_for_async_insert = 1 "
"VALUES (now(), 1, 'view', '/', 10, 'US')"
)
Prefer sending a moderately sized batch per call (hundreds or thousands of rows) over single rows even when async inserts are on; the per-statement overhead of parsing and the network round trip is what async inserts cannot remove.
Gotchas
- Errors arrive late. The data is parsed and checked against the table when it is processed, not when you send it. With
wait_for_async_insert = 0you will never see that error, which is another reason to keep waiting enabled while you develop. - Test with the real client path. Asynchronous insert is a server feature, but how it behaves depends on how your client sends data (statement with
VALUES, or a data stream in the request body). Measure with your actual driver. - Deduplication. Retries after a timeout can double-insert.
async_insert_deduplicate = 1(default 0) deduplicates identical retried inserts on replicated tables, at some cost. - Visibility. Data is not visible to
SELECTuntil the flush. Do not read-after-write against the same table withwait_for_async_insert = 0. - Monitoring.
system.asynchronous_insertsshows what is currently buffered, and theasynchronous_insert_logtable (when enabled) records flushes with their status and errors.
Decision guide
| Situation | Choice |
|---|---|
| You control the writer | Batch in the client; async inserts are unnecessary |
| Many small writers, must not lose data | async_insert = 1, wait_for_async_insert = 1, high concurrency |
| Many small writers, loss acceptable | async_insert = 1, wait_for_async_insert = 0 |
| One huge bulk load | Synchronous large inserts, or ATTACH PARTITION FROM |
Next: inserting into Distributed tables.
Reference: asynchronous inserts in the docs.
Discussion
Comments live in GitHub Discussions. Sign in with GitHub to join in.