Skip to content

Bulk import

Bulk import inserts many new rows into one existing table. It is intended for prepared datasets that should pass through the same server rules as rows entered individually.

It is not a file-upload or CSV API. The server does not read a file, recognize headers, map source columns, convert spreadsheet values, or split a large dataset into requests. A client performs those steps and sends already mapped row values to the table-data bulk operation.

One request identifies:

  • one profile;
  • one table in that profile;
  • an ordered list containing between 1 and 10,000 rows.

Every row is a map from the table’s current public column aliases to typed values. A request cannot mix profiles or tables. The server also applies a 128 MiB decoding limit to the table-data service, so a request may reach the message-size limit before reaching 10,000 rows when rows contain large values.

Rows are processed sequentially in request order. For each row, the server performs the normal insert lifecycle:

  1. Resolve the profile and table definition.
  2. Reject unknown, internal, and read-only inputs.
  3. Enrich and validate structured field types.
  4. Apply configured field validation rules.
  5. Execute the table’s Steel scripts and compare submitted computed values.
  6. Resolve links and, for accounting-enabled tables, the account path.
  7. Insert the row in its own database transaction.
  8. Apply quantity-ledger effects and accounting effects in that transaction.
  9. Record temporal source values and the new row revision.
  10. Record a durable Search indexing job and commit the row.

This is not a PostgreSQL COPY shortcut. Preserving the ordinary row lifecycle is what keeps imported data consistent with interactive inserts.

The bulk request reuses a disposable Steel-engine cache for repeated scripts within that request. This reduces script startup work without sharing mutable script state with another request.

Bulk import requires the same insert permission as an ordinary row insert. Permission is checked against the requested table’s family root, so the grant follows the same template-family rules as other table-data operations.

There is no separate bulk-import or administrator permission. Structural roles, including admin and superadmin, cannot write row data. The caller must be a data role with insert access to the target table family.

Use current public column aliases, including renamed aliases. Do not send PostgreSQL physical column names.

Column kind Value to send
Text String; an empty string remains an empty string
Boolean Boolean
INT Whole protobuf number within the 32-bit range
BIGINT and links Whole protobuf number when exactly representable, or an integer string
DECIMAL and MONEY Decimal string, never a floating-point number
Temporal types String in the format required by that temporal type
Null Explicit null, where the column permits it

Omitting a value lets the database default or nullability rule apply. Required validation rejects an omitted or null value. Nested object and list values are not supported as table fields.

For BIGINT, use a string outside the exact protobuf-number range of ±9,007,199,254,740,991. Decimal and money strings preserve exact values and avoid floating-point rounding before the server sees them.

Temporal values are interpreted using the authenticated user’s configured timezone where the type requires it. Phone enrichment uses that user’s configured phone country. This means an import runs with the importing user’s preferences, not a separate timezone or country supplied by the bulk request.

A link contains the referenced row ID. The referenced row must already exist when its child row is processed, or that row fails.

Import tables in dependency order—for example, customers before invoices and invoices before invoice lines. Because every successful row commits before the next row begins, a later row can refer to an earlier imported row when the necessary ID is known.

Bulk import does not ask the server to fill computed targets. The client must include each Steel target and supply the value it calculates. The server runs the authoritative script and rejects the row if the submitted value differs.

This includes scripts that read links, aggregates, or current table state. Because rows commit in order, later scripts can observe earlier imported rows. Changing input order can therefore change an aggregate-dependent result.

See Steel row data and validation and Dependencies and hydration for the computation model.

An accounting-enabled table requires account as a slash-delimited account path on every imported row. The account must already exist with the expected denomination currency. The normal accounting date, journal, closed-period, currency-conversion, and balance rules apply.

Each successful accounting row and its journal/account effects commit atomically. The batch as a whole is not atomic, so a failure can leave a valid but incomplete imported journal dataset. Plan reconciliation before importing accounting rows.

Quantity-ledger columns and accounting-transfer source columns are server-owned and cannot be supplied. Steel quantity effects are reconciled inside the same transaction as their source row.

Every inserted row records a Search outbox job in the same transaction as the row. After success—or after a later row fails—the server wakes the background indexer for the IDs already inserted.

Indexing is asynchronous. A successful bulk response means the PostgreSQL rows committed; it does not guarantee that original Search already returns them. The durable outbox allows indexing to continue after a temporary worker or notification problem. Search2 and Analytics read PostgreSQL directly.

Continue with Failures, retries, and batching before implementing an importer.