Benchmarking Core Executemany Bulk Insert Performance

To benchmark SQLAlchemy Core executemany bulk inserts reliably, wrap each strategy in a warmed-up time.perf_counter() loop over a fixed row set, then cross-check the server-side cost in pg_stat_statements — never trust a single cold run. This page is the measurement companion to the parent guide on high-performance bulk inserts and updates, which covers the upsert, RETURNING, and synchronize_session strategies you will be benchmarking here.

Isolating variables one at a time — batch size, row width, insertmanyvalues_page_size, index count — and confirming the actual SQL shape with EXPLAIN (ANALYZE, BUFFERS) is what turns anecdotal "asyncpg is fast" claims into reproducible rows-per-second numbers you can defend in a capacity review. Because raw insert speed is bounded by driver encoding and pool contention, benchmark only after you have configured your async engine and connection pool the same way production runs it.

Quick Answer

# Naive benchmark — one cold run, times everything including compile + cache miss
import time
t0 = time.perf_counter()
await conn.execute(insert(orders_table), rows)  # first call also pays statement compilation
print(time.perf_counter() - t0)                 # misleading: 2-5x slower than steady state

# Reliable benchmark — pre-build rows, warm up, take the median of N runs
import asyncio
import time
from statistics import median
from sqlalchemy import insert, text, 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 measure_insert(engine: AsyncEngine, rows: list[dict], label: str, runs: int = 5) -> float:
    stmt = insert(orders_table)
    samples: list[float] = []
    for i in range(runs + 1):                       # +1 warm-up run, discarded below
        async with engine.begin() as conn:
            await conn.execute(text("TRUNCATE orders RESTART IDENTITY"))
            t0 = time.perf_counter()
            await conn.execute(stmt, rows)          # dict list pre-built outside the timed block
            samples.append(time.perf_counter() - t0)
    steady = median(samples[1:])                    # drop the cold first sample
    rps = len(rows) / steady
    print(f"{label}: {rps:,.0f} rows/sec ({steady:.3f}s median for {len(rows):,} rows)")
    return rps

# Run: asyncio.run(measure_insert(engine, rows, "Core executemany"))

The single-run form on top is the mistake almost every ad-hoc benchmark makes: the first execute() also pays SQLAlchemy statement compilation and a cold buffer cache, so it reports throughput two-to-five times worse than steady state. The median-of-runs form below is the pattern the rest of this page builds on.

A benchmark that measures the thing you care about Four conditions. First, benchmark against a table with production-scale row counts and the production index set: an empty table with no indexes flatters every method equally and ranks them wrongly. Second, discard the first run of each variant — cold caches, a cold connection pool and an unwarmed statement cache make it unrepresentative. Third, keep the transaction boundary identical across variants, because a variant that commits per chunk is measuring fsync, not insert throughput. Fourth, report rows per second so the numbers stay comparable when the row count changes. benchmark against a production-shaped table same row count, same indexes, same constraints an empty unindexed table ranks the methods wrongly discard the first run of every variant cold pool, cold cache, unwarmed statement cache and run at least five, reporting the median hold the transaction boundary constant one variant committing per chunk measures fsync not insert throughput — the two differ by an order of magnitude report rows per second, not elapsed seconds so the figure survives a change in row count and record the row width alongside it Row width matters as much as row count: a table of four small columns and a table with a JSONB payload behave so differently that their numbers should never be compared.

Execution Context & Async Workflow Integration

What the Benchmark Is Actually Measuring

A naive time.perf_counter() wrapper around a single conn.execute() call measures end-to-end wall time including:

  • Python list construction and dict iteration
  • SQLAlchemy statement compilation (cached after first call)
  • asyncpg binary parameter encoding
  • TCP round-trip(s) to the database server
  • PostgreSQL WAL write and index maintenance
  • Python-side result fetch (if RETURNING is used)

To isolate the SQLAlchemy + driver overhead from PostgreSQL server overhead, run the benchmark with the database on localhost (Unix socket preferred), then repeat on a production-representative network. The delta quantifies network latency's contribution and tells you whether further Python-side optimization will matter. The choice of driver dominates the encoding cost measured here, so a fair comparison depends on choosing between asyncpg and psycopg deliberately rather than benchmarking whichever one happened to be installed.

Benchmark Setup: Consistent Baseline

Before comparing strategies, establish a consistent baseline environment:

import asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine

async def prepare_benchmark(engine: AsyncEngine, target_rows: int = 100_000) -> list[dict]:
    """Reset table and generate synthetic rows for benchmark."""
    async with engine.begin() as conn:
        await conn.execute(text("TRUNCATE TABLE orders RESTART IDENTITY"))

    return [
        {
            "customer_id": i % 10_000,
            "amount": f"{(i % 500) + 0.99:.2f}",
            "status": "pending" if i % 3 else "completed",
        }
        for i in range(target_rows)
    ]

Always TRUNCATE between runs to avoid index bloat from prior inserts skewing subsequent measurements. Restart the sequence to prevent PK gaps that inflate index page splits.

Strategy Comparison with time.perf_counter

import asyncio
import time
from itertools import islice
from sqlalchemy import insert
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker

class Base(DeclarativeBase):
    pass

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    customer_id: Mapped[int]
    amount: Mapped[str]   # simplified for benchmark
    status: Mapped[str]

async def benchmark_all(engine: AsyncEngine, rows: list[dict]) -> None:
    SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

    # --- Strategy 1: ORM add_all ---
    instances = [Order(**r) for r in rows]
    async with SessionLocal() as session:
        t0 = time.perf_counter()
        session.add_all(instances)
        await session.commit()
        elapsed = time.perf_counter() - t0
    print(f"ORM add_all:          {len(rows)/elapsed:>10,.0f} rows/s")

    async with engine.begin() as conn:
        await conn.execute(__import__("sqlalchemy").text("TRUNCATE orders RESTART IDENTITY"))

    # --- Strategy 2: ORM bulk execute (session.execute + insert) ---
    async with SessionLocal() as session:
        t0 = time.perf_counter()
        await session.execute(insert(Order), rows)
        await session.commit()
        elapsed = time.perf_counter() - t0
    print(f"ORM bulk execute:     {len(rows)/elapsed:>10,.0f} rows/s")

    async with engine.begin() as conn:
        await conn.execute(__import__("sqlalchemy").text("TRUNCATE orders RESTART IDENTITY"))

    # --- Strategy 3: Core executemany ---
    stmt = insert(orders_table)
    t0 = time.perf_counter()
    async with engine.begin() as conn:
        await conn.execute(stmt, rows)
    elapsed = time.perf_counter() - t0
    print(f"Core executemany:     {len(rows)/elapsed:>10,.0f} rows/s")

    async with engine.begin() as conn:
        await conn.execute(__import__("sqlalchemy").text("TRUNCATE orders RESTART IDENTITY"))

    # --- Strategy 4: insertmanyvalues (RETURNING) ---
    stmt_ret = insert(orders_table).returning(orders_table.c.id)
    t0 = time.perf_counter()
    async with engine.begin() as conn:
        result = await conn.execute(stmt_ret, rows)
        _ = result.fetchall()
    elapsed = time.perf_counter() - t0
    print(f"insertmanyvalues:     {len(rows)/elapsed:>10,.0f} rows/s")

Representative Throughput Numbers

These numbers were measured on a c5.xlarge EC2 instance connecting to db.r6g.xlarge RDS PostgreSQL 15 via private subnet, with 100,000 rows of the shape above (3 non-PK columns, no foreign keys, no extra indexes). The absolute spread between the ORM unit-of-work and the raw COPY path is roughly 40x, which is why the Core vs ORM overhead decision matters far more for ingestion than for OLTP:

Insert throughput by strategy (rows/sec, 100k rows) A horizontal bar chart comparing five bulk insert strategies by measured rows per second. Bars grow from ORM add_all at the bottom of the range to asyncpg COPY at roughly 40 times higher; the Core executemany bar is highlighted as the focus of this benchmark page. Insert throughput by strategy — 100,000 rows, no extra indexes 0 100k 200k 300k 400k rows / second — higher is better ORM add_all() 11,000 ORM bulk execute 78,000 Core executemany 85,000 insertmanyvalues + RETURNING 195,000 asyncpg COPY 440,000
Strategyrows/secNotes
ORM add_all()~11,000Full unit-of-work: attribute instrumentation, identity map, flush
ORM session.execute(insert(Order), rows)~78,000Skips unit-of-work; still applies Python-side column defaults
Core conn.execute(insert(table), rows)~85,000Pure executemany; no ORM overhead
insertmanyvalues with RETURNING~195,000Batched multi-value INSERT, fewer round-trips
asyncpg COPY~440,000Binary wire protocol; no SQL parsing

Add indexes and foreign keys and all numbers drop 20–60% depending on index selectivity. Profile your specific schema rather than relying on synthetic benchmarks.

Resolving Warnings, Errors & Common Mistakes

Warning / ErrorRoot CauseProduction Fix
NotImplementedError: executemany_modeOlder SQLAlchemy/asyncpg combinations where executemany_mode was a dialect-level kwarg (pre-2.0).Remove executemany_mode; it is deprecated. SQLAlchemy 2.0 auto-selects the optimal path.
Benchmark shows no difference between strategiesStatement compilation cache is cold on first run; early runs inflate timing.Warm up each strategy with a 100-row run before recording, or run 3 iterations and take the median.
EXPLAIN ANALYZE shows Seq Scan during insertMissing ANALYZE after a large insert — table statistics are stale, planner estimates are wrong.Run ANALYZE orders after bulk load; for recurring loads add autovacuum_analyze_threshold = 0 on the table.
asyncpg.exceptions.PostgresSyntaxError during insertmanyvaluesColumn name mismatch between dict keys and table column names.Validate dict keys against {c.name for c in table.c} before calling execute.
TimeoutError during long benchmark runscommand_timeout on asyncpg connection is shorter than the bulk insert duration.Set connect_args={"command_timeout": None} for ingestion connections, or increase to match expected duration.
Throughput degrades after first chunkWAL checkpoint triggered mid-run — checkpoint_completion_target too low.Increase max_wal_size and checkpoint_completion_target=0.9 in postgresql.conf for bulk load sessions.
Numbers inconsistent between runsAutovacuum running concurrently during benchmark, causing lock contention.Run SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE application_name = 'autovacuum' during benchmark, or disable autovacuum on the table temporarily.

Advanced Benchmarking Optimization

Using EXPLAIN (ANALYZE, BUFFERS) to Confirm Strategy

SQLAlchemy does not expose what SQL it actually sent for insertmanyvalues in production logs. Use echo=True on the engine temporarily, or inspect pg_stat_statements to verify the rewritten statement shape:

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine

async def inspect_insert_statements(engine: AsyncEngine) -> None:
    """Print the top insert statements from pg_stat_statements by total_time."""
    async with engine.connect() as conn:
        result = await conn.execute(text("""
            SELECT
                left(query, 120) AS query_snippet,
                calls,
                round((total_exec_time / calls)::numeric, 2) AS avg_ms,
                rows / calls AS avg_rows
            FROM pg_stat_statements
            WHERE query ILIKE '%INSERT%orders%'
            ORDER BY total_exec_time DESC
            LIMIT 10
        """))
        for row in result:
            print(f"avg {row.avg_ms}ms | {row.avg_rows} rows/call | {row.query_snippet}")

insertmanyvalues rewrites the single-row INSERT INTO orders (customer_id, amount, status) VALUES ($1, $2, $3) into multi-value form. You should see one statement with high avg_rows (up to insertmanyvalues_page_size) rather than many single-row statements.

Varying Batch Sizes Systematically

Throughput is not linear with batch size. Run a sweep across chunk sizes to find the elbow:

import asyncio
import time
from sqlalchemy import insert
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
from sqlalchemy import text

async def batch_size_sweep(engine: AsyncEngine, all_rows: list[dict]) -> None:
    stmt = insert(orders_table)
    for chunk_size in [100, 500, 1_000, 2_000, 5_000, 10_000, 25_000]:
        async with engine.begin() as conn:
            await conn.execute(text("TRUNCATE orders RESTART IDENTITY"))

        t0 = time.perf_counter()
        async with engine.begin() as conn:
            for i in range(0, len(all_rows), chunk_size):
                await conn.execute(stmt, all_rows[i:i + chunk_size])
        elapsed = time.perf_counter() - t0
        print(f"chunk={chunk_size:>6}: {len(all_rows)/elapsed:>10,.0f} rows/s")

Typical results: throughput rises steeply from 100 to ~2,000 rows per chunk, then plateaus or slightly decreases above 10,000 due to memory allocation and WAL pressure. The optimal chunk size for your workload sits at the plateau's left edge. When your source data is larger than memory, feed the sweep from a generator rather than a materialized list — the memory-safe chunking pattern is covered in batch inserting millions of rows with SQLAlchemy Core.

Advanced Profiling Techniques

Correlating Python Timing with pg_stat_statements

Python-side time.perf_counter() measures wall time including event loop scheduling latency, GIL contention, and network jitter. For a clean server-side view, cross-reference with pg_stat_statements:

Attribute the time before you tune anything A four-way decomposition of a slow load. Total wall clock is what you first observe. Statement compilation is measured by comparing a run with the compiled-statement cache warm against one with cache_size zero; if it dominates, the fix is to stop rebuilding the statement per chunk. Parameter binding and type coercion is measured with cProfile on the Python side; if it dominates, the fix is cheaper types, for example passing datetimes rather than strings that must be parsed. Network and server time is measured by comparing against the same insert issued through the raw driver; if it dominates, the fix is a larger chunk or COPY. Index and constraint maintenance is measured by repeating the load against a copy of the table with indexes dropped; if that run is far faster, the fix is on the table, not the statement. total wall clock the only number you started with split it before tuning statement compilation compare warm cache vs cache_size=0 fix: build the statement once binding and type coercion cProfile the Python side fix: cheaper types, fewer coercions network and server execution compare against the raw driver fix: larger chunks, or COPY index and constraint maintenance repeat against a copy with indexes dropped Teams usually tune the chunk size because it is the easiest knob, but on an indexed table the last row of this list is frequently the largest single term.
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine

async def server_side_timing(engine: AsyncEngine, label: str) -> None:
    """Print server-side execution stats for recent INSERT statements."""
    async with engine.connect() as conn:
        result = await conn.execute(text("""
            SELECT
                left(query, 100)      AS query_preview,
                calls,
                round(mean_exec_time::numeric, 2) AS mean_ms,
                rows / calls          AS rows_per_call
            FROM pg_stat_statements
            WHERE query ILIKE '%INSERT%orders%'
            ORDER BY total_exec_time DESC
            LIMIT 5
        """))
        print(f"\n=== {label} ===")
        for row in result:
            print(f"  {row.mean_ms}ms avg | {row.rows_per_call} rows/call | {row.query_preview}")

Run SELECT pg_stat_statements_reset() before each benchmark strategy to isolate its statistics. If rows_per_call is 1 for an insert you expect to be batched, insertmanyvalues is not activating — check that .returning() is present and the dialect supports it.

Profiling Index Maintenance Cost

Index maintenance during bulk inserts is often the dominant cost for tables with non-trivial indexes. Isolate this by benchmarking with and without indexes:

from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
import time

async def benchmark_with_without_indexes(engine: AsyncEngine, rows: list[dict]) -> None:
    stmt = __import__("sqlalchemy").insert(orders_table)

    # Without indexes
    async with engine.begin() as conn:
        await conn.execute(text("DROP INDEX IF EXISTS idx_orders_customer"))
        await conn.execute(text("TRUNCATE orders RESTART IDENTITY"))
    t0 = time.perf_counter()
    async with engine.begin() as conn:
        await conn.execute(stmt, rows)
    no_idx_time = time.perf_counter() - t0

    # With index
    async with engine.begin() as conn:
        await conn.execute(text("CREATE INDEX idx_orders_customer ON orders(customer_id)"))
        await conn.execute(text("TRUNCATE orders RESTART IDENTITY"))
    t0 = time.perf_counter()
    async with engine.begin() as conn:
        await conn.execute(stmt, rows)
    with_idx_time = time.perf_counter() - t0

    overhead_pct = (with_idx_time - no_idx_time) / no_idx_time * 100
    print(f"Index overhead: {overhead_pct:.1f}% slower ({no_idx_time:.2f}s → {with_idx_time:.2f}s)")

A single B-tree index on a narrow column typically adds 20–40% overhead. For tables with 5+ indexes, the index maintenance cost can exceed the raw insert cost. In those cases, consider dropping indexes before bulk load and rebuilding with CREATE INDEX CONCURRENTLY after commit — a standard ETL pattern.

Frequently Asked Questions

How do I measure only the database time, excluding Python serialization?

Record the time immediately before and after await conn.execute(stmt, chunk), with the dict list pre-built outside the timed block. For server-side measurement, use pg_stat_statements.mean_exec_time per call — this excludes all client-side overhead including network RTT and Python result parsing.

Should I use executemany_mode in SQLAlchemy 2.0?

No. executemany_mode was an asyncpg-dialect-level parameter in SQLAlchemy 1.4. In 2.0 it is replaced by insertmanyvalues, which is dialect-agnostic and automatically activated when RETURNING is present. If you see executemany_mode in existing code, remove it during your 1.4 → 2.0 migration.

What is a realistic baseline to expect on RDS PostgreSQL?

On db.r6g.xlarge RDS over a private VPC connection, Core executemany with asyncpg typically delivers 70,000–120,000 rows/sec for 3–5 column rows without extra indexes. Add two additional indexes and expect a 30–50% reduction. asyncpg COPY on the same hardware delivers 350,000–500,000 rows/sec. These ranges widen significantly with row width, index selectivity, and network RTT — and with how the pool is sized, so match the benchmark to how you have tuned connection pools for cloud databases in that environment.

How do I ensure my benchmark reflects production conditions?

Use the same instance class, same Postgres version, same indexes, same autovacuum settings, and same connection pool configuration as production. Run the benchmark during a simulated concurrent read load — bulk inserts under concurrent OLTP traffic behave differently than in an idle database due to lock contention and buffer cache pressure.

Why does my benchmark show higher throughput on the second run than the first?

PostgreSQL's buffer cache is cold on the first run — the table and index pages must be loaded from disk. Subsequent runs find those pages already in shared buffers and operate entirely in memory. Always warm up with at least one full pass before recording results, and consider running benchmarks with pg_prewarm to pre-load table pages for a controlled comparison.