Skip to content

Insertar miles de filas en bloque snippet

Inserta miles de filas rápido: un solo viaje de ida y vuelta con N tuplas de valores, no N viajes de una fila cada uno — la diferencia entre milisegundos y minutos a escala.

Inserta miles de filas rápido: un solo viaje de ida y vuelta con N tuplas de valores, no N viajes de una fila cada uno — la diferencia entre milisegundos y minutos a escala. Las tres palancas: la sentencia multi-VALUES (Postgres la limita a 65535 parámetros de enlace), la API de batch del driver (executemany, copy-from, batch) y el COPY de Postgres — el cargador masivo que supera cualquier forma de INSERT. La regla contra la inyección no se relaja: el enlace por lotes sigue usando placeholders, nunca tuplas construidas con strings.

Receta ejecutable · 11 lenguajes
Databases & SQLsqlpostgresbulk-insertbatchcopyinsertperformance

Every language

11 lenguajes, copy-ready. One at a time with syntax highlighting, or all inline.

SQLSQLrunnable
-- one statement, one round trip — 2 rows here, thousands scale the same way:
INSERT INTO users (name, email) VALUES
    ('Ada', 'ada@x.test'),
    ('Grace', 'grace@x.test')
RETURNING id;

-- the ceiling does the arithmetic for you: Postgres allows at most 65535 bind
-- parameters per statement, so rows-per-chunk = floor(65535 / columns) —
-- 2 columns => 32767 rows max per statement. Chunk there, not at a guess.

Multi-VALUES is the base form every driver layer in the other tabs compiles down to. The 65535 cap counts bind parameters, not rows — divide by your column count. Literal tuples like these are fine for seed data; application data arrives as parameters, and a batch is never an excuse to string-build them. Runnable as-is in the SQL playground (users table exists there).

Run in the SQL playground →
JSJavaScript
// node-postgres: build $1..$N placeholders, flatten the rows, send ONE statement:
const cols = ['name', 'email'];
const rows = [['Ada', 'ada@x.test'], ['Grace', 'grace@x.test']];

const tuples = rows
  .map((_, r) => `(${cols.map((_, c) => `$${r * cols.length + c + 1}`).join(', ')})`)
  .join(', ');
const sql = `INSERT INTO users (${cols.join(', ')}) VALUES ${tuples}`;
await pool.query(sql, rows.flat()); // 2 rows x 2 cols = $1..$4, one round trip

// the faster lane: COPY FROM STDIN via pg-copy-streams beats every INSERT form

The builder math is the trap: placeholder index = rowIndex * cols.length + colIndex + 1 — an off-by-one silently inserts Grace's email as Ada's. Only placeholders go in the SQL string; the data stays bound in the flat args array, never string-concatenated. Neither pg nor pg-promise has a built-in batch API — the VALUES builder or COPY (pg-copy-streams) are the two real lanes.

TSTypeScript
type Row = Record<string, string | number>;

function buildInsert(
  table: string,
  rows: Row[],
): { sql: string; args: (string | number)[] } {
  const cols = Object.keys(rows[0]);
  const tuples = rows.map((row, r) =>
    `(${cols.map((_, i) => `$${r * cols.length + i + 1}`).join(', ')})`);
  const args = rows.flatMap((row) => cols.map((c) => row[c]));
  return {
    sql: `INSERT INTO ${table} (${cols.join(', ')}) VALUES ${tuples.join(', ')}`,
    args,
  };
}

// the cap chunker: MAX_PARAMS = 65535, chunk = Math.floor(MAX_PARAMS / cols.length)
for (const chunk of chunkRows(rows, 65535 / cols.length)) {
  await pool.query(buildInsert('users', chunk));
}

The cap is the point: floor(65535 / columns) rows per statement, loop until done — a 100k-row insert built as ONE statement dies at bind time with 'too many parameters', not at row 1. Table and column identifiers are the only interpolated parts and must come from an allowlist; values always stay in args.

GoGo
import (
	"database/sql"
	"fmt"
	"strings"
)

// one statement per chunk: VALUES ($1,$2),($3,$4),... + one flat args slice:
func insertChunk(db *sql.DB, rows [][2]string) error {
	var b strings.Builder
	b.WriteString("INSERT INTO users (name, email) VALUES ")
	args := make([]any, 0, len(rows)*2)
	for i, r := range rows {
		if i > 0 {
			b.WriteByte(',')
		}
		fmt.Fprintf(&b, "($%d,$%d)", i*2+1, i*2+2) // $1,$2 / $3,$4 ...
		args = append(args, r[0], r[1])
	}
	_, err := db.Exec(b.String(), args...) // ONE round trip for the whole chunk
	return err
}

// chunk rows at 65535/2 = 32767 per statement, call insertChunk per chunk

database/sql has NO batch API — db.Exec inside a loop is N round trips, the exact thing being avoided. String-join only the $n placeholders; data lives in the flat args slice and never enters the SQL string. The genuinely fast path is pgx's CopyFrom — it speaks the Postgres COPY protocol and beats every INSERT form.

RsRust
use sqlx::query_builder::QueryBuilder;

// push_values builds one multi-VALUES statement with every value bound:
let mut qb = QueryBuilder::new("INSERT INTO users (name, email) ");
qb.push_values(&rows, |mut b, (name, email)| {
    b.push_bind(name).push_bind(email);
});
qb.build().execute(&pool).await?;

// the faster lane is Postgres COPY — tokio_postgres::Client::copy_in:
//   let sink = client.copy_in("COPY users FROM STDIN (FORMAT csv)").await?;
//   sink.send(csv_bytes).await?; // one stream, no statement, no binds

push_values scales to thousands of tuples with parameters still bound — no hand-built VALUES string. Beyond that COPY wins: tokio-postgres's copy_in streams bytes with no statement parsing at all. On COPY the values are NOT bound — the text/CSV format is the protocol, so escaping tabs, newlines, and quotes is on the caller.

PHPPHP
// one prepare, a (?,?) x N placeholder string, one execute with a flat array:
$rows = [['Ada', 'ada@x.test'], ['Grace', 'grace@x.test']];

$placeholders = implode(', ', array_fill(0, count($rows), '(?, ?)'));
$stmt = $pdo->prepare("INSERT INTO users (name, email) VALUES {$placeholders}");
$stmt->execute(array_merge(...$rows)); // flat: a0,a1,b0,b1 — one round trip

array_fill builds the placeholder string; array_merge(...$rows) flattens the rows in placeholder order — the two must agree or values silently shift columns. The caps: Postgres 65535 bind parameters per statement, MySQL max_allowed_packet per packet — chunk at the smaller of the two. Laravel's insert()/insertOrIgnore() and Doctrine's batch layers wrap this exact pattern; wrap chunks in a transaction for InnoDB speed.

PyPython
# psycopg3 — executemany is NOW one round trip (pipeline mode):
cur.executemany(
    'INSERT INTO users (name, email) VALUES (%s, %s)',
    rows,
)

# psycopg2 needed the workaround — one multi-VALUES statement instead:
#   from psycopg2.extras import execute_values
#   execute_values(cur, 'INSERT INTO users (name, email) VALUES %s', rows)

# the bulk loader — beats every INSERT form:
with cur.copy('COPY users (name, email) FROM STDIN') as copy:
    for name, email in rows:
        copy.write_row((name, email))

executemany in psycopg2 was secretly one-round-trip-per-row — psycopg3 rewrote it on pipeline mode and it finally means batch. execute_values is the psycopg2 escape hatch: it collapses to a single multi-VALUES statement. cur.copy() is psycopg3's COPY binding (copy_expert's successor) and write_row handles the escaping COPY's text format demands.

C#C#
// Postgres: Npgsql's binary COPY — the bulk loader:
await using (var writer = await conn.BeginBinaryImportAsync(
                 "COPY users (name, email) FROM STDIN (FORMAT BINARY)"))
{
    foreach (var (name, email) in rows)
    {
        await writer.StartRowAsync();
        await writer.WriteAsync(name, NpgsqlDbType.Text);
        await writer.WriteAsync(email, NpgsqlDbType.Text);
    }
    await writer.CompleteAsync(); // omit this and the whole COPY rolls back
}

// SQL Server instead: SqlBulkCopy is its native bulk path:
//   using var bulk = new SqlBulkCopy(conn) { DestinationTableName = "users" };
//   bulk.WriteToServer(dataTable);

Rule of thumb: multi-VALUES INSERT for dozens of rows, COPY for thousands — BeginBinaryImport is Npgsql's binding of Postgres COPY, and CompleteAsync is the commit (forgetting it discards every row you streamed). SqlBulkCopy is the SQL Server equivalent with its own column-mapping setup. Plain INSERT loops use no bulk path at all — the same N-round-trip trap as everywhere else.

JvJava
String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
    conn.setAutoCommit(false);
    int n = 0;
    for (String[] row : rows) {
        ps.setString(1, row[0]);
        ps.setString(2, row[1]);
        ps.addBatch();                     // accumulate — never execute per row
        if (++n % 1000 == 0) ps.executeBatch(); // flush periodically
    }
    ps.executeBatch();                     // the tail
    conn.commit();
}

addBatch/executeBatch is the standard JDBC form — but without a rewrite hint the driver can still ship one statement per row. MySQL: append ?rewriteBatchedStatements=true to the JDBC URL and the batch becomes a multi-VALUES packet. Postgres: reWriteBatchedInserts=true does the same rewriting. Commit once per batch; auto-commit per statement re-opens the N-round-trip cost as N fsyncs.

KtKotlin
connection.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)").use { ps ->
    connection.autoCommit = false
    rows.chunked(1000).forEach { chunk -> // the batch-size tuning knob
        chunk.forEach { (name, email) ->
            ps.setString(1, name)
            ps.setString(2, email)
            ps.addBatch()
        }
        ps.executeBatch()
    }
    connection.commit()
}

chunked(1000) is the trade-off knob: bigger batches amortize round trips but grow memory and lock time — ~1000 rows per executeBatch is the usual sweet spot. use { } closes the statement on every exit path. Same rewrite hints as Java apply (reWriteBatchedInserts=true on the PG URL); without one the JDBC batch may still go row-by-row.

RbRuby
require 'sequel'

# import(columns, array-of-arrays) = one multi-VALUES statement, values bound:
DB[:users].import(
  [:name, :email],
  [['Ada', 'ada@x.test'], ['Grace', 'grace@x.test']],
  slice: 500, # chunks at 500 rows per statement
)
# multi_insert(rows_of_hashes) is the same API in hash shape

# the pg gem's COPY path — fastest possible:
#   DB.copy_data('COPY users (name, email) FROM STDIN') do |copy|
#     rows.each { |name, email| copy.put_copy_data("#{name}\t#{email}\n") }
#   end

Sequel's import/multi_insert IS the batch API, and :slice keeps each statement under the 65535 bind-parameter cap automatically (default 500). ActiveRecord's insert_all is the Rails-world multi-VALUES equivalent. The pg gem's copy_data speaks raw COPY and beats them all — but on COPY the \t / \n formatting and escaping are the caller's job.