Batch Inserting Millions of Rows with SQLAlchemy Core

To batch insert millions of rows with SQLAlchemy 2.0 Core, pass a list of dicts to await conn.execute(insert(table), chunk) inside engine.begin() — the engine delegates to the driver's native executemany path, which on asyncpg uses binary protocol encoding and a single server-side prepared statement for the entire batch. This page is the memory-safe, production-scale companion to the parent guide on high-performance bulk inserts and updates, which covers upsert, RETURNING, and synchronize_session in full context.

Quick Answer

# Legacy 1.4 — session.bulk_insert_mappings (removed in 2.0)
session.bulk_insert_mappings(Order, list_of_dicts)

# Modern 2.0 — Core async bulk insert with explicit chunking
import asyncio
from itertools import islice
from sqlalchemy import insert, Table, MetaData, Column, Integer, String, Numeric
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine

metadata = MetaData()
orders_table = Table(
    "orders", metadata,
    Column("id", Integer, primary_key=True),
    Column("customer_id", Integer, nullable=False),
    Column("amount", Numeric(12, 4), nullable=False),
    Column("status", String(32), nullable=False),
)

async def batch_insert(engine: AsyncEngine, rows: list[dict], chunk_size: int = 2_000) -> None:
    stmt = insert(orders_table)
    it = iter(rows)
    async with engine.begin() as conn:
        while chunk := list(islice(it, chunk_size)):
            await conn.execute(stmt, chunk)

engine.begin() wraps each block in an auto-committing transaction — no conn.commit() call required. To commit per-chunk (for very large datasets where a single transaction would exhaust WAL), move engine.begin() inside the loop. The Core path here is deliberate: going through insert(table) rather than the ORM sidesteps the unit-of-work machinery described in Core vs ORM architecture decisions, which is the single largest throughput lever for ingestion workloads.

One execute with a list, not one execute per row Two shapes of the same insert. On the left, a Python loop calling await conn.execute once per row: each iteration parses, binds and round-trips separately, so a million rows means a million round trips and the network dominates entirely. On the right, a single await conn.execute passing the whole chunk as a list of dicts: SQLAlchemy detects the sequence and switches to executemany, parsing once and sending the parameter sets together. The note records that the chunk, not the total, is what has to fit in memory. per-row execute — the shape to delete for row in rows: await conn.execute(insert(orders), row) one parse, bind and round trip per row 1 000 000 rows ≈ 1 000 000 round trips one execute, many parameter sets await conn.execute(insert(orders), chunk) chunk is a list[dict] — executemany fires parsed once, parameters streamed together 1 000 000 rows ≈ 500 round trips at chunk 2 000 The trigger is the parameter type, not a keyword: a list of dicts selects executemany, a single dict does not. Chunk the iterator so only chunk_size rows are ever resident.

Execution Context & Async Workflow Integration

How executemany Maps to the Driver

When conn.execute(stmt, list_of_dicts) receives a sequence, SQLAlchemy compiles the insert statement once and hands the parameter list to the underlying DBAPI's executemany() method. On asyncpg this translates to:

  1. A single Parse message — the server prepares the statement once.
  2. N Bind + Execute messages, each carrying one row's parameters in efficient binary format.
  3. A single Sync message to flush and confirm.
One Core executemany Call vs a Naive Per-Row Insert Loop A naive loop issues four protocol messages per row — Parse, Bind, Execute, Sync — so the server re-parses and re-plans the INSERT once for every row. Passing a list of dicts to conn.execute compiles the statement once: the driver sends a single Parse, then one Bind plus Execute per row with parameters in binary format, then a single Sync to flush and confirm the entire batch. No SQL string is re-parsed per row. What SQLAlchemy Core sends over the asyncpg wire Naive loop — one execute() per row: the server re-parses the INSERT every row row 1 Parse Bind Execute Sync row 2 Parse Bind Execute Sync row N Parse Bind Execute Sync 4 messages × N rows Parse repeated N times = re-planned every row await conn.execute(insert(t), chunk) — one prepared statement for the whole batch Parse once Bind+Execute row 1 · binary params Bind+Execute row 2 · binary params Bind+Execute row N · binary params Sync flush & confirm 1 Parse  +  N Bind/Execute  +  1 Sync  —  statement compiled once, no text-to-value coercion Narrow table on typical cloud hardware: ~60,000–100,000 rows/s (Core executemany) vs ~8,000–15,000 rows/s for ORM add_all() of the same row shape

No SQL string is re-parsed per row. The binary encoding avoids text-to-value coercion overhead on both the Python and PostgreSQL sides. The net effect is that asyncpg sustains 60,000–100,000 rows per second on typical cloud hardware for narrow tables, compared to 8,000–15,000 for ORM add_all() with the same row shape — a gap quantified strategy-by-strategy in benchmarking Core executemany bulk insert performance.

One caveat with the "single server-side prepared statement" behaviour: if your ingestion connections route through PgBouncer in transaction pooling mode, asyncpg's prepared statements break across pooled backends. See handling asyncpg prepared statement errors with PgBouncer for the statement_cache_size=0 fix before pointing a bulk loader at a pooled endpoint.

Generator-Based Streaming for Multi-Million Row Sources

Loading an entire CSV or upstream API response into a Python list before inserting causes MemoryError at scale. Use a generator that yields row dicts on demand:

import csv
from typing import Iterator

def stream_orders_from_csv(filepath: str) -> Iterator[dict]:
    """Yield one dict per CSV row — never holds more than one row in memory."""
    with open(filepath, encoding="utf-8", newline="") as fh:
        for row in csv.DictReader(fh):
            yield {
                "customer_id": int(row["customer_id"]),
                "amount": row["amount"],       # Decimal-compatible string
                "status": row.get("status", "pending"),
            }

Pair this with the chunking pattern from the Quick Answer section. islice materializes only chunk_size dicts at a time, keeping Python heap usage flat regardless of source size. This is the write-side mirror of streaming large result sets with yield_per: both patterns keep memory bounded by processing the dataset in fixed-size windows rather than materializing it whole.

Per-Chunk Transaction Boundaries

For data sets larger than ~500,000 rows, a single transaction is impractical because:

  • PostgreSQL's WAL accumulates all changes before flushing — a single 10 M row transaction can generate 10–40 GB of WAL.
  • Autovacuum cannot reclaim dead tuples inside an open transaction.
  • Any failure forces a full rollback of all previously inserted rows.

Commit per chunk and track a resumption checkpoint to support safe restarts:

import asyncio
from itertools import islice
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy import Table, insert

async def resumable_bulk_insert(
    engine: AsyncEngine,
    table: Table,
    data_stream,
    chunk_size: int = 2_000,
    start_offset: int = 0,
) -> int:
    """Returns total rows inserted; supports restart from start_offset."""
    stmt = insert(table)
    total = 0
    it = islice(data_stream, start_offset, None)   # skip already-inserted rows

    while chunk := list(islice(it, chunk_size)):
        async with engine.begin() as conn:          # one transaction per chunk
            await conn.execute(stmt, chunk)
        total += len(chunk)
    return total

Async Concurrency: Parallel Chunk Workers

For I/O-bound pipelines with multiple CPU-bound data sources, run chunk insertions concurrently using asyncio.gather(). Use a semaphore to cap database connections:

import asyncio
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy import Table, insert

async def parallel_bulk_insert(
    engine: AsyncEngine,
    table: Table,
    chunks: list[list[dict]],
    max_concurrency: int = 5,
) -> None:
    sem = asyncio.Semaphore(max_concurrency)
    stmt = insert(table)

    async def insert_chunk(chunk: list[dict]) -> None:
        async with sem:
            async with engine.begin() as conn:
                await conn.execute(stmt, chunk)

    await asyncio.gather(*[insert_chunk(c) for c in chunks])

Keep max_concurrency at or below pool_size to prevent connection starvation. Sizing that pool correctly is its own topic — configuring async engines and connection pools covers the pool_size/max_overflow math for concurrent writers. Monitor pg_stat_activity for wait_event = 'ClientRead' spikes that indicate connection queue pressure.

Resolving Warnings, Errors & Common Mistakes

Warning / ErrorRoot CauseProduction Fix
InterfaceError: connection already closedAwaiting on a connection whose underlying asyncpg socket closed due to server timeout or pool recycle.Set pool_pre_ping=True on create_async_engine() (see configuring pool_pre_ping to handle stale connections) and ensure command_timeout in connect_args exceeds your longest expected chunk insert time.
ProgrammingError: can't operate on a closed result setIterating a CursorResult after the transaction that produced it has committed or rolled back.Consume the result set (e.g., result.fetchall()) inside the engine.begin() block, or use .returning() and collect IDs before the context exits.
asyncpg.exceptions.TooManyConnectionsErrorMore concurrent coroutines than pool_size + max_overflow are requesting connections simultaneously.Reduce max_concurrency in parallel insert patterns, or increase pool_size and max_overflow on create_async_engine().
sqlalchemy.exc.StatementError: could not convert value to type int4Python value types don't match column types — e.g., string "123" for an Integer column.Cast values explicitly before inserting: int(row["customer_id"]). SQLAlchemy does not silently coerce in executemany paths.
MemoryErrorMaterializing millions of row dicts into a single list before passing to execute.Stream from source using a generator and chunk with islice — never accumulate the full dataset.
OperationalError: server closed the connection unexpectedlyLong-running transaction exceeds idle_in_transaction_session_timeout on the server.Chunk to shorter transactions, or set server_settings={"idle_in_transaction_session_timeout": "0"} in connect_args to disable the timeout for ingestion connections.
DataError: value too long for type character varying(N)Source data contains strings wider than the column definition.Validate and truncate upstream; use String columns with length=None (TEXT) if variable length is expected.

Advanced Bulk Insert Optimization

insertmanyvalues vs Standard executemany

SQLAlchemy 2.0 introduced insertmanyvalues: when the backend supports RETURNING (PostgreSQL, SQLite ≥ 3.35), and the statement includes .returning(), SQLAlchemy rewrites the insert as batched multi-value INSERT ... VALUES (...), (...) RETURNING ... grouped in pages of up to insertmanyvalues_page_size rows (default 1000).

Prepare the table, not just the statement Four preparation steps that usually outweigh any further tuning of the insert itself. First, drop secondary indexes that are not needed during the load: every index is maintained per row, so five indexes make roughly five times the write work. Second, defer foreign-key validation by marking constraints deferrable and checking them at commit, so each row does not pay a lookup. Third, raise maintenance_work_mem for the session that will rebuild the indexes, since an in-memory sort is far faster than an external one. Fourth, rebuild indexes and run ANALYZE in a single pass at the end, before any query planning depends on the new statistics. The caution notes that all of this is only worth it for a load that dominates the table. drop secondary indexes first each index is maintained per inserted row five indexes ≈ five times the write work defer foreign-key validation SET CONSTRAINTS ALL DEFERRED inside the load transaction checked once at COMMIT instead of per row raise maintenance_work_mem for the rebuild an in-memory sort beats an external merge decisively set it on the loading session only, never globally rebuild indexes, then ANALYZE one pass at the end, before anything plans against the table stale statistics after a large load cause bad plans Worth it when the load is large relative to the table. For an incremental load into a large existing table, dropping and rebuilding the indexes costs far more than it saves.

This is measurably faster than standard executemany for workloads that need to capture generated PKs, because it reduces round-trips from N (one per row) to ceil(N / page_size). For workloads that do not need RETURNING, standard executemany with asyncpg is comparably fast.

from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncEngine

async def insert_with_returning(
    engine: AsyncEngine, rows: list[dict]
) -> list[int]:
    stmt = (
        insert(orders_table)
        .returning(orders_table.c.id)
        .execution_options(insertmanyvalues_page_size=2_000)
    )
    async with engine.begin() as conn:
        result = await conn.execute(stmt, rows)
        return [row.id for row in result]

asyncpg COPY for Maximum Throughput

When throughput requirements exceed 200,000 rows per second, abandon the SQL insert path entirely and use asyncpg's binary COPY protocol. Access the raw connection via conn.get_raw_connection():

import asyncpg
from sqlalchemy.ext.asyncio import AsyncEngine

async def copy_orders(engine: AsyncEngine, records: list[tuple]) -> None:
    """
    records: list of (customer_id: int, amount: Decimal, status: str)
    Column 'id' is omitted — PostgreSQL generates it via SERIAL/GENERATED.
    """
    async with engine.connect() as conn:
        raw: asyncpg.Connection = await conn.get_raw_connection()
        await raw.copy_records_to_table(
            "orders",
            records=records,
            columns=["customer_id", "amount", "status"],
            timeout=120.0,
        )
        await conn.commit()

COPY bypasses row-by-row statement processing and streams data in the PostgreSQL wire binary format. It is 3–6× faster than insertmanyvalues for large batches. Constraint verification still occurs at commit — validate FK integrity before calling COPY to avoid full-batch rollbacks.

Advanced Batch Insert Patterns

Backpressure and Rate Limiting in Long-Running Pipelines

In production ingestion pipelines, inserting as fast as the database will accept data without any pacing causes two problems: it exhausts connection pool slots from concurrent workers, and it spikes WAL generation faster than PostgreSQL's background writer can flush to disk. Adding a concurrency semaphore provides implicit backpressure:

import asyncio
from sqlalchemy import insert, Table
from sqlalchemy.ext.asyncio import AsyncEngine

async def paced_bulk_insert(
    engine: AsyncEngine,
    table: Table,
    chunks: list[list[dict]],
    max_concurrent: int = 4,
) -> None:
    sem = asyncio.Semaphore(max_concurrent)
    stmt = insert(table)

    async def insert_one(chunk: list[dict]) -> None:
        async with sem:
            async with engine.begin() as conn:
                await conn.execute(stmt, chunk)

    await asyncio.gather(*[insert_one(c) for c in chunks])

Set max_concurrent to match pool_size on the engine. Running more concurrent insert coroutines than available pool slots causes them to queue inside SQLAlchemy's connection pool, which is fine for throughput but creates long pool-wait latencies that mask the actual database-side bottleneck.

Validating Row Schema Before Insert

Silent type coercion failures are the hardest bulk insert bugs to diagnose: PostgreSQL may accept a string "123" for an INTEGER column through the text protocol, but asyncpg's binary protocol will reject it with DataError: invalid input for query argument. Pre-validate dict keys and types before passing to execute:

from sqlalchemy import Table
from typing import Any

def validate_rows(table: Table, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Ensure all rows have only known column keys and no None in NOT NULL columns."""
    valid_keys = {col.name for col in table.c}
    nullable = {col.name for col in table.c if col.nullable}
    errors = []
    for i, row in enumerate(rows):
        unknown = set(row) - valid_keys
        if unknown:
            errors.append(f"Row {i}: unknown keys {unknown}")
        for col in table.c:
            if not col.nullable and col.name != "id" and row.get(col.name) is None:
                errors.append(f"Row {i}: NULL in NOT NULL column '{col.name}'")
    if errors:
        raise ValueError(f"Row validation failed:\n" + "\n".join(errors[:10]))
    return rows

Run this validation on a sample of the first chunk during development, then disable it or move it to a separate offline validation step for production throughput.

Frequently Asked Questions

Does engine.begin() issue one transaction for all chunks or one per chunk?

One per engine.begin() block. If you write the chunking loop inside a single engine.begin(), all chunks share one transaction. If you place engine.begin() inside the loop, each chunk gets its own transaction with its own commit. Use the inner placement for large datasets to limit WAL growth and allow autovacuum to operate on committed data between chunks.

How do I handle duplicate key violations during bulk insert?

Use insert(table).on_conflict_do_nothing() to silently skip conflicting rows, or on_conflict_do_update() to upsert. Both work with executemany and with insertmanyvalues. Catching IntegrityError at the Python level requires re-inserting the entire chunk row by row, which is impractical at scale and should be avoided.

Can I use this pattern with SQLite in async tests?

Yes, with aiosqlite. Replace the DSN with sqlite+aiosqlite:///test.db. Note that aiosqlite does not support the binary protocol optimizations asyncpg provides, so throughput is lower. For test databases, use check_same_thread=False in connect_args and avoid concurrent writers — SQLite's write lock is file-level and concurrent inserts will produce OperationalError: database is locked.

Is it safe to share a single AsyncEngine across multiple async tasks doing bulk inserts?

Yes — AsyncEngine is designed for concurrent use. Each engine.begin() or engine.connect() call checks out a separate connection from the pool. Set pool_size and max_overflow to accommodate your expected concurrency level. As a rule of thumb, set pool_size equal to the number of concurrent insert coroutines you expect to run simultaneously.

What happens if a chunk insert fails partway through a multi-chunk pipeline?

If each chunk has its own engine.begin() block, only the failing chunk rolls back — all previously committed chunks are durable. Implement a checkpoint (e.g., writing the last committed offset to a status table) so you can resume from the failure point without re-inserting already-committed data.