python 77 lines · 3 tabs

Efficient Bulk Insert in SQLAlchemy with a Reusable Chunking Helper

Shared by codesnips Aug 2026
3 tabs
import hashlib
from datetime import datetime, timezone

from sqlalchemy import BigInteger, Column, DateTime, String, UniqueConstraint
from sqlalchemy.orm import declarative_base

Base = declarative_base()


class Event(Base):
    __tablename__ = "events"
    __table_args__ = (UniqueConstraint("dedupe_key", name="uq_events_dedupe"),)

    id = Column(BigInteger, primary_key=True)
    source = Column(String(64), nullable=False)
    name = Column(String(128), nullable=False)
    payload = Column(String, nullable=False, default="")
    dedupe_key = Column(String(64), nullable=False)
    created_at = Column(DateTime(timezone=True), nullable=False)

    @classmethod
    def to_row(cls, raw):
        source = raw["source"]
        name = raw["name"]
        payload = raw.get("payload", "")
        digest = hashlib.sha256(f"{source}:{name}:{payload}".encode()).hexdigest()
        return {
            "source": source,
            "name": name,
            "payload": payload,
            "dedupe_key": digest[:64],
            "created_at": raw.get("created_at") or datetime.now(timezone.utc),
        }
3 files · python Explain with highlit

Inserting tens of thousands of rows one session.add at a time is slow because each row generates its own INSERT round-trip and the ORM tracks every object in the identity map. This snippet shows how to push large batches through SQLAlchemy Core while keeping the ergonomics of a model, and how to chunk arbitrary iterables so memory stays flat.

The models.py tab defines a plain Event mapped class plus a to_row classmethod that normalizes a dict into exactly the columns the table expects, filling defaults like created_at and a computed dedupe_key. Keeping this mapping in one place means the loader never hand-builds partial dicts that drift from the schema, which is the usual source of NULL constraint surprises during bulk loads.

The chunked helper in batching.py is deliberately generic: it consumes any iterable lazily via iter and islice, yielding lists of at most size. Because it never materializes the whole input, it works on a streaming cursor or a multi-gigabyte file just as well as a list. The while True loop stops when islice returns an empty chunk, which is the clean way to detect exhaustion without peeking ahead.

The bulk_loader.py tab ties them together. insert_events maps raw dicts through Event.to_row, then feeds chunked so each database round-trip carries batch_size rows. It uses insert(Event).values(rows) — a Core multi-values INSERT — rather than the ORM unit-of-work, avoiding per-object flush overhead. on_conflict_do_nothing from the Postgres dialect makes the load idempotent against the dedupe_key unique index, so re-running a partially applied batch is safe. Each chunk commits inside its own transaction so a failure late in a large load doesn't roll back everything already written.

The trade-off is that bypassing the ORM means no automatic relationship cascades or Python-side default execution, so to_row must supply everything the row needs. For pure inserts of independent rows this is exactly the right tool; when relationship graphs or per-row events matter, the regular session flow is still preferable. Tuning batch_size balances round-trip count against statement size and lock duration — a few thousand rows per statement is a common sweet spot.


Related snips

Share this code

Here's the card — post it anywhere.

Efficient Bulk Insert in SQLAlchemy with a Reusable Chunking Helper — share card
Link copied