Back to BlogArchitecture & Engineering

CSV Import, Export and Bulk Actions in a SaaS

Rupak Amin

Founder & Lead Engineer, RAITHub

7 min read

RAITHub ships and tests production software. See QA as a Service or talk to us.

CSV import, export and bulk actions move data in and out of your SaaS in large batches. Validate the whole file before you write anything, make the import idempotent so a re-run never duplicates rows, report errors per row with line numbers, and run anything large as a background job, not inside the web request.

If you would rather have import, export and bulk actions built for you, see how RAITHub would build this below.

Why do import and bulk features fail more than they look like they should?

Because the happy path, a clean file with ten rows, hides every real problem: a 50,000-row file that times out the request, a user who uploads the same file twice, a row 4,312 with a bad date that leaves rows 1 to 4,311 written and the rest not. Users import their most important data through this feature, so a half-finished import is worse than none.

RAITHub built bulk data handling on Sundor Skin, a B2B wholesale platform with 146 PostgreSQL tables, row-level security and 530+ tests, where product and price data arrives in large batches and a duplicated or partial import would corrupt a buyer's catalogue.

Should import run in the request or as a background job?

As a background job for anything above a few hundred rows. A web request that tries to parse and insert a large file will hit a timeout, and the user has no idea whether it worked. The job-and-progress model is the fix.

StepIn the requestAs a background job
UploadReceive the file, store it, create a job record—
Validate—Parse and check every row, collect errors
Write—Insert or update valid rows idempotently
ReportPoll or subscribe for progress and the error reportUpdate a progress and results record

The job runner that executes this, and how to make it reliable, is in a scheduler and recurring jobs for your SaaS.

How do you validate a whole file before writing anything?

Parse into rows, validate every row against a schema, and only start writing if the file passes your policy. Collect errors with their line number so the user can fix the file. Decide up front whether a partial import is allowed; "all or nothing" is safest for financial or catalogue data.

import { z } from 'zod'

const Row = z.object({
  email: z.string().email(),
  name:  z.string().min(1),
  role:  z.enum(['member', 'admin']),
})

// Returns valid rows and per-line errors; writes nothing.
export function validate(rows: unknown[]) {
  const valid: z.infer<typeof Row>[] = []
  const errors: { line: number; message: string }[] = []
  rows.forEach((raw, i) => {
    const parsed = Row.safeParse(raw)
    if (parsed.success) valid.push(parsed.data)
    else errors.push({ line: i + 2, message: parsed.error.issues[0].message }) // +2: header + 1-indexed
  })
  return { valid, errors }
}

Report the errors as a downloadable list the user can act on, not a single "import failed". Line numbers turn a frustrating dead end into a two-minute fix.

How do you make an import idempotent?

Pick a natural key per row and upsert on it, so importing the same file twice produces the same result, not duplicates. For whole-file safety, give the import a client-supplied key and record which rows that import already wrote.

-- Upsert on a natural key (e.g. email within a workspace), so a re-run updates, never duplicates.
INSERT INTO contacts (workspace_id, email, name, role)
VALUES ($1, $2, $3, $4)
ON CONFLICT (workspace_id, email)
DO UPDATE SET name = EXCLUDED.name, role = EXCLUDED.role
RETURNING (xmax = 0) AS inserted;   -- true = created, false = updated

The xmax = 0 trick lets you report "120 created, 35 updated" honestly. Idempotency is the same discipline that makes payment and webhook handling safe; the general idea is in sending webhooks to your SaaS customers. If a user re-uploads after a crash, the upsert means the second run simply finishes the job.

What does export and bulk-action need to get right?

Export looks trivial and has its own traps; bulk actions are small imports with the same rules.

  • Export large sets as a job too. Generating a 200,000-row CSV in a request times out. Build it in the background and hand the user a download link when it is ready.
  • Scope every export to the tenant. An export endpoint that forgets the tenant filter is a data leak. Apply the same scoping as every query, per the Postgres row-level security guide.
  • Stream, do not buffer. Write rows to the output as you read them from the database, so memory stays flat regardless of row count.
  • Bulk actions are transactional per batch. "Delete 500 selected" or "archive all matching" should run in batches, each in a transaction, with a count of what was affected and an undo window where it matters.

Buy, build or hire?

OptionExamplesChoose this whenWatch out for
Hosted CSV-import widgetEmbeddable importer SaaS with mapping UIYou want a column-mapping UI and validation UX fast, for simple record typesData passes through their service; custom validation and idempotency against your rules can be limited
LibraryA streaming CSV parser plus your own validationYou want control of validation and writes with less UI workYou build the job, progress and error reporting yourself
Build it inThe validate-then-upsert pattern aboveImports touch core, high-value data with your own correctness rulesYou own idempotency, partial-failure policy and large-file handling
Hire a team to build itRAITHub or another studioImport correctness matters to money or a catalogue and must be testedInsist the handover includes the re-run and partial-failure tests

Do-it-yourself estimate: 4–8 days for the upload, background validate-and-write job, idempotent upserts, per-row error reporting and a streamed export, if a job runner exists. The main risk is a partial import that writes half a file and leaves the user unsure what landed.

How RAITHub would build this

As part of a new SaaS build, or added to an existing product, scoped in writing after the free audit.

  • Background pipeline: upload, validate the whole file, then write valid rows, with a progress record the UI reads.
  • Idempotent writes: upsert on a natural key, with an honest "created vs updated" count and safe re-runs.
  • Per-row error reporting: a downloadable list with line numbers, and a clear partial-versus-all-or-nothing policy.
  • Streamed, tenant-scoped export and transactional bulk actions, with affected counts and an undo window where it matters.

Timeline: inside a new product, this is part of the 4–6 week fixed-scope SaaS build; added to an existing backend, it fits the 6–12 week backend range, with the exact scope in the quote.

You receive: automated tests and CI for re-runs and partial failures, handover docs, and full IP assigned to you under NDA.

Next step: a free 15-minute technical audit, then a written fixed quote. See the SaaS development service, or book the audit.

Frequently asked questions

Should CSV import run in the web request or a background job?

A background job for anything above a few hundred rows. A request will time out on a large file and leave the user unsure whether it worked. Create a job on upload, process it in the background, and report progress and errors back.

How do I stop a re-uploaded file creating duplicate rows?

Upsert on a natural key such as email within a workspace, so importing the same file twice updates rather than duplicates. Then a re-run after a crash simply finishes the job instead of doubling the data.

Should a bad row fail the whole import?

That is a policy you choose. For catalogue or financial data, all-or-nothing is safest, so nothing writes unless the file is clean. For contact lists, importing valid rows and reporting the rest by line number is often friendlier.

How should I report import errors to users?

Per row, with the line number and a specific message, as a downloadable list they can fix and re-upload. A single "import failed" gives the user no way to find the three bad rows in a file of thousands.

How do I export large datasets without crashing?

Generate the file as a background job and stream rows from the database to the output instead of building it all in memory. Hand the user a download link when it is ready, and scope the query to their tenant so nothing leaks.

CSV importBulk actionsData importIdempotencyValidationSaaS

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.