Optimize slow PostgreSQL inserts in Python using psycopg2. Use when: (1) Row-by-row inserts are taking too long over network, (2) executemany() isn't providing speedup, (3) Migrating large datasets to PostgreSQL, (4) Network latency making individual INSERT statements impractical. The key is using execute_values() from psycopg2.extras instead of executemany() or individual execute() calls.
Scanned 5/27/2026
Install via CLI
openskills install divinevideo/divine-mobile---
name: psycopg2-batch-insert-optimization
description: |
Optimize slow PostgreSQL inserts in Python using psycopg2. Use when: (1) Row-by-row
inserts are taking too long over network, (2) executemany() isn't providing speedup,
(3) Migrating large datasets to PostgreSQL, (4) Network latency making individual
INSERT statements impractical. The key is using execute_values() from psycopg2.extras
instead of executemany() or individual execute() calls.
author: Claude Code
version: 1.0.0
date: 2025-01-20
---
# psycopg2 Batch Insert Optimization
## Problem
When inserting thousands of rows into PostgreSQL over a network connection, row-by-row
inserts are extremely slow. Each INSERT requires a round-trip, and with network latency
of ~50-100ms, inserting 10,000 rows takes 10+ minutes.
The naive approach of using `cursor.executemany()` doesn't help much—it still sends
individual statements.
## Context / Trigger Conditions
- Inserting >100 rows into PostgreSQL via psycopg2
- Each insert taking ~1 second or more
- Network latency to database (especially Cloud SQL, RDS, remote databases)
- Migration scripts running for hours
- `executemany()` not providing expected speedup
## Solution
Use `execute_values()` from `psycopg2.extras`:
```python
from psycopg2.extras import execute_values
# Instead of this (SLOW):
for row in data:
cursor.execute("INSERT INTO table (a, b, c) VALUES (%s, %s, %s)", row)
# Or this (STILL SLOW):
cursor.executemany("INSERT INTO table (a, b, c) VALUES (%s, %s, %s)", data)
# Use this (FAST):
execute_values(cursor, """
INSERT INTO table (a, b, c)
VALUES %s
ON CONFLICT (id) DO NOTHING
""", data, page_size=500)
conn.commit()
```
### Key Parameters:
- `page_size`: Number of rows per batch (default 100, try 500-1000)
- The `VALUES %s` placeholder is replaced with multiple value tuples
### For UPSERT operations:
```python
execute_values(cursor, """
INSERT INTO users (user_id, username, email)
VALUES %s
ON CONFLICT (user_id) DO UPDATE SET
username = EXCLUDED.username,
email = COALESCE(EXCLUDED.email, users.email)
""", user_data, page_size=500)
```
### Progress Monitoring for Long Migrations:
```python
import sys
sys.stdout.reconfigure(line_buffering=True) # Force unbuffered output
BATCH_SIZE = 500
for i in range(0, len(data), BATCH_SIZE):
batch = data[i:i+BATCH_SIZE]
execute_values(cursor, query, batch)
conn.commit()
print(f"Processed {min(i+BATCH_SIZE, len(data))}/{len(data)} rows...")
```
## Verification
- Migration that previously took hours completes in minutes
- You can see batches being processed in real-time with progress output
- Check row counts after: `SELECT COUNT(*) FROM table`
## Example
Real-world migration of 9,563 users from SQLite to PostgreSQL:
```python
from psycopg2.extras import execute_values
import sys
sys.stdout.reconfigure(line_buffering=True)
BATCH_SIZE = 500
# Fetch from SQLite
sqlite_cur.execute('SELECT user_id, username, avatar_url, verified FROM users')
rows = sqlite_cur.fetchall()
data = [(r['user_id'], r['username'], r['avatar_url'], bool(r['verified']))
for r in rows]
# Batch insert to PostgreSQL
for i in range(0, len(data), BATCH_SIZE):
batch = data[i:i+BATCH_SIZE]
execute_values(pg_cur, '''
INSERT INTO users (user_id, username, avatar_url, verified)
VALUES %s
ON CONFLICT (user_id) DO UPDATE SET
username = COALESCE(EXCLUDED.username, users.username),
avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url)
''', batch)
pg_conn.commit()
print(f"Processed {min(i+BATCH_SIZE, len(data))}/{len(data)} users...")
```
**Result**: 9,563 users migrated in ~20 seconds instead of ~2.5 hours.
## Notes
- `execute_values()` constructs a single INSERT with multiple VALUES, drastically
reducing round-trips
- `executemany()` is deceptively slow—it still sends individual statements
- For very large datasets (>100k rows), consider `COPY` command or `copy_expert()`
- The `page_size` parameter controls memory usage vs. batch efficiency
- Always commit after each batch for long migrations (allows progress tracking and
partial recovery)
### SQLite to PostgreSQL Syntax Differences:
When migrating, also watch for these SQL differences:
- `INSERT OR IGNORE` → `ON CONFLICT DO NOTHING`
- `INSERT OR REPLACE` → `ON CONFLICT DO UPDATE SET ...`
- `MAX(a, b)` (SQLite) → `GREATEST(a, b)` (PostgreSQL)
- `MIN(a, b)` (SQLite) → `LEAST(a, b)` (PostgreSQL)
- `?` placeholders → `%s` placeholders
- `AUTOINCREMENT` → `SERIAL` or `GENERATED ALWAYS AS IDENTITY`
## References
- [psycopg2 execute_values documentation](https://www.psycopg.org/docs/extras.html#psycopg2.extras.execute_values)
- [PostgreSQL COPY for bulk loading](https://www.postgresql.org/docs/current/sql-copy.html)
No comments yet. Be the first to comment!