Skip to content
VibORM
Esc
navigateopen⌘Jpreview
On this page

createMany

Create multiple records in a single operation

Create multiple records in one call:

const result = await client.user.createMany({
  data: [
    { email: "alice@example.com", name: "Alice" },
    { email: "bob@example.com", name: "Bob" },
    { email: "carol@example.com", name: "Carol" },
  ],
});
// Result: { count: 3 }

createMany returns a { count }. Each row takes the same data a single create takes, nested relation writes included — see Writing relations below.

A direct polymorphic field is the one relation that stays connect-only in a bulk row: each row may provide connect, which VibORM resolves into that row’s private type-and-identity membership, and a required polymorphic field must provide it. See Root createMany with polymorphic relations.

Writing relations

await client.user.createMany({
  data: [
    {
      email: "alice@example.com",
      profile: { create: { bio: "Hi" } },        // one fresh child for this row
      team: { connectOrCreate: {                  // resolved per row, in order
        where: { name: "core" },
        create: { name: "core" },
      } },
    },
    {
      email: "bob@example.com",
      team: { connectOrCreate: {                  // the same team as row 1
        where: { name: "core" },
        create: { name: "core" },
      } },
    },
  ],
});
// Result: { count: 2 } — and one team, not two.

Rows run left to right, each one exactly as if you had called create with it. That order is the contract, not an artifact: row 2 sees what row 1 wrote, which is why two rows naming the same connectOrCreate target converge on one row instead of racing to create two.

What follows from it:

  • count is the number of root rows inserted — nested children are not counted.
  • Transaction-capable drivers roll everything back together. A failure in any row undoes every row.
  • Drivers with native atomic batches keep the same successful result and row order, with a different failure boundary. Without an interactive transaction, VibORM runs safe ordered segments after each previous batch returns a normalized success. If a later segment fails, earlier segments can remain committed and the error reports committed or possibly committed progress. A driver’s strong committed-segment capability adds callback-before-decode attribution; it is not required for this route. See D1 transactions and batching for one provider-specific example.
  • A driver with neither interactive transactions nor native atomic batches rejects this relation-bearing form. Rows that stay on the grouped INSERT path — scalar rows and the direct polymorphic connect above — are unaffected.

With skipDuplicates, a conflicting root row suppresses its complete nested record subtree. VibORM does not apply that row’s relation effects to the existing record:

await client.user.createMany({
  data: [
    {
      email: "already-exists@example.com",
      posts: { create: { title: "also skipped" } },
    },
  ],
  skipDuplicates: true,
});

An interactive transaction isolates the complete member with a savepoint. A batch-only route isolates a root-first skippable INSERT, inspects its exact row count, and dispatches descendants only when that root inserted. A zero-row root therefore suppresses its descendants. If a write or nested record series must run before the skippable root, VibORM refuses the shape before any operation write: suppressing that root would otherwise strand the earlier effect.

Explicit $transaction([...]) arrays remain one indivisible atomic batch. They do not use the progressive fallback and refuse when the dynamic series cannot lower exactly into that one batch.

Skip duplicates

const result = await client.user.createMany({
  data: [
    { email: "alice@example.com", name: "Alice" },
    { email: "alice@example.com", name: "Alice Duplicate" }, // Skipped
  ],
  skipDuplicates: true,
});
// Result: { count: 1 }

skipDuplicates skips unique/primary-key conflicts only. Foreign-key, NOT NULL, validation, conversion, and other integrity failures still reject the whole operation on every supported database.

A row containing only database-owned defaults cannot use skipDuplicates. VibORM rejects that shape before writing because no supported database shares a duplicate-only DEFAULT VALUES primitive.

For relation-bearing rows, a skipped root suppresses its whole nested subtree. Interactive savepoints and safe root-first batch isolation implement that same meaning; see Writing relations.

Getting the rows back

Add a select and createMany returns the created rows instead of a count. The return type follows: with select it is an array of the projected rows, without it, { count }.

const users = await client.user.createMany({
  data: [
    { email: "alice@example.com", name: "Alice" },
    { email: "bob@example.com", name: "Bob" },
  ],
  select: { id: true, email: true },
});
// Type: { id: string; email: string }[]

select and skipDuplicates can only be combined on a database with RETURNING (PostgreSQL, SQLite, LibSQL, D1). On MySQL the combination is refused with a typed error rather than guessed at, because a skipped insert cannot be told apart from a fresh one — drop select to get { count }, or drop skipDuplicates.

When the rows carry relations, the returned rows are read after every row has finished, in input order, so a child written by a later row cannot leave an earlier row’s projection stale. VibORM groups those final reads into bounded set queries, normally one query; large inputs are split only to respect the driver’s parameter limit. If a later row moved an earlier row’s primary key — legal whenever a key member is also a foreign key — the read finds nothing, and the call fails rather than hand back a shorter list. The { count } form of the same payload succeeds.

What it costs

The scalar path uses one grouped INSERT per contiguous row shape (plus the skip-duplicate preparation it always used). When that statement would exceed a driver’s verified bound-parameter limit, VibORM compiles the largest fitting contiguous chunks and keeps them in the same transaction or native atomic batch. Counts and returned rows concatenate in input order. A single row that cannot fit remains indivisible and is rejected before database I/O.

Chunking changes one database-visible detail only for a payload that could not previously execute: a statement-level trigger fires once per chunk. Row-level triggers still fire once per row, and an under-limit row run remains one statement.

On an interactive driver, a relation-bearing call costs one transaction plus, per row, exactly what that row would have cost as a single create — the same target probes, the same child writes, in the same order. A no-transaction driver with native batching sends one atomic batch per safe executable segment instead. Ten rows that each connect a target issue ten probes, because that is what ten create calls issue. Adding a select adds one or more bounded set reads at the end, normally one, rather than one public-result read per row.

Options

await client.user.createMany({
  data: [{ ... }],      // Required: array of records — the ordinary create data
                        // shape, nested relation writes included
  skipDuplicates: true, // Optional: skip duplicate-key conflicts; with relation
                        // writes, the complete skipped subtree is suppressed
  select: { ... },      // Optional (scalar fields only): return the created rows
                        // instead of { count }
});

Was this page helpful?