> ## Documentation Index
> Fetch the complete documentation index at: https://clickhouse.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Asynchronous INSERT ... SELECT

> How INSERT ... SELECT takes the asynchronous insert queue route, and the routing, accounting, and cancellation rules that apply to it.

`INSERT ... SELECT` can take the [asynchronous insert](/docs/optimize/asynchronous-inserts) queue route, not just a plain `INSERT ... VALUES`. This page describes when it does, how the write side is accounted, and what happens when the query is cancelled or times out.

<h2 id="eligibility-and-routing">
  Eligibility and routing
</h2>

An `INSERT ... SELECT` takes the async queue route only when all of these hold:

* `async_insert = 1`.
* `async_insert_select_as_async_insert = 1` (the default). This is the master switch: with it off, `INSERT ... SELECT` is always synchronous regardless of `async_insert`. Setting `compatibility` to a version before 26.10 flips its default to `0`; an explicit `async_insert_select_as_async_insert = 1` opts back in.
* The whole `SELECT` result is a single block no larger than `async_insert_max_data_size`.
* The destination is a `MergeTree`-family table (an alias is followed to its target for this check).

Even when all of the above hold, the insert falls back to the synchronous route in any of these cases:

* The result spans more than one block, exceeds `async_insert_max_data_size`, or is empty.
* Non-parallel quorum is used: `insert_quorum` is set and `insert_quorum_parallel = 0`.
* The destination has dependent materialized views.
* The destination is a materialized view. The `MergeTree` check applies to the view, not its target table, so it is not eligible.
* The destination is a table function, or a remote table engine such as `Distributed`.
* The `SELECT` reads its own destination table (`INSERT INTO t SELECT ... FROM t`).
* `insert_null_as_default` must substitute a default for a `Nullable` `SELECT` column feeding a non-`Nullable` target column.
* The insert is internal (refreshable materialized view refresh, `POPULATE`, or `CREATE TABLE ... AS SELECT`). These are always synchronous and ignore `async_insert_select_as_async_insert` and `async_insert`.

A transaction is handled differently. When the query runs inside a transaction (explicit, or `implicit_transaction = 1`), the async route is unsupported and, by default, ClickHouse raises `NOT_IMPLEMENTED` rather than falling back. Only with `throw_on_unsupported_query_inside_transaction = 0` does the query fall back to a synchronous insert.

Routing chosen before the async gate wins, so the `SELECT` side can rule the async route out on its own. With `parallel_distributed_insert_select` (on by default), an `INSERT ... SELECT` that reads from a cluster source such as `s3Cluster` into a replicated table runs on the distributed insert path and never reaches the async queue. Parallel replicas behave the same way.

<h2 id="return-mode-and-waiting">
  Return mode and waiting
</h2>

`wait_for_async_insert` works as for any async insert: `1` (the default) waits for the flush; `0` returns as soon as the block is queued.

To help a `SELECT` produce the single block the async route requires: [`optimize_trivial_insert_select`](/docs/reference/settings/session-settings/optimize-trivial#optimize_trivial_insert_select) caps a trivial `INSERT INTO dest SELECT ... FROM source` between distinct tables to [`max_insert_threads`](/docs/reference/settings/session-settings/max-insert#max_insert_threads) reading threads. Set `max_insert_threads = 1` and the `SELECT` commonly produces one block.

<h2 id="write-accounting-and-observability">
  Write accounting and observability
</h2>

The write side is attributed to the flush query, not to the original `INSERT ... SELECT`:

* The flush query owns the write `ProfileEvents` (`InsertedRows`, `InsertedBytes`) and gets its own write-side `system.query_log` row. The original `INSERT ... SELECT` row reads `0` for those `ProfileEvents`. To see the flush's own write numbers, read its row in `system.query_log` and `system.asynchronous_insert_log`.
* With `wait_for_async_insert = 1` (the default), the query's own `written_rows` and `written_bytes` (and the HTTP `X-ClickHouse-Summary` header) report the rows and bytes accepted from the `SELECT`, not the flush query's own total. A failed flush still surfaces as an error to the client.
* With `wait_for_async_insert = 0`, those numbers are not reported, because the query returns before the flush result exists.
* The `WRITTEN_BYTES` quota is charged once, on the flush path, and is not rolled back if the flush fails.

<h2 id="cancellation-and-timeouts">
  Cancellation and timeouts
</h2>

The block is queued before the wait for the flush starts, so cancelling the query stops the client from waiting, not the queue from flushing.

* `KILL QUERY`, or reaching `max_execution_time` during the wait, returns an error to the client, but the rows may still be written. Do not treat a cancelled or timed-out query on this route as proof that nothing was written.
* With `timeout_overflow_mode = 'break'` there is no error and no early return: the query waits for the flush past `max_execution_time`, up to `wait_for_async_insert_timeout`. Breaking out early would report success before the write is confirmed.

<h2 id="when-decisions-are-bound">
  When decisions are bound
</h2>

Eligibility for the async route is decided once at query start, before the block is queued, and an up-front access check rejects an unauthorized insert early. Whether an `INSERT ... SELECT` takes the async route at all depends on the result's block shape, not just on `async_insert`.

The destination lookup and the binding authorization then run at flush time, the same as for [any asynchronous insert](/docs/optimize/asynchronous-inserts): a materialized view attached to the destination, or a grant revoked, between queueing and flush affects the flush.
