Every SaaS product eventually ships a button that says Import. It looks like a small feature: accept a CSV, loop over rows, write to the database. In production it is one of the riskiest write paths you own. The input is untrusted and often malformed. The work runs longer than any request timeout. Workers crash halfway through. Users click twice. Files contain personal data that belongs to someone else. And every row has to land in the correct tenant.
This article describes a proposed design for bulk data import in multi-tenant SaaS, aimed at the tech lead who owns the pipeline and is paged when it goes wrong. The design is an import state machine, a tenant-scoped operation identity bound to an immutable approved payload, quarantine for uncertain rows, and atomic chunk commits with a ledger. It is not a proprietary method Developers.dev claims to run for every client. It is a set of patterns you can adapt, with the trade-offs stated plainly.
The code samples are illustrative. They use PostgreSQL-style SQL because its INSERT ... ON CONFLICT behavior is well documented. Verify the details against your own database and driver before you adopt them.
- Persona: Tech lead or senior engineer building or hardening a file import path.
- Scope: User-uploaded files (CSV and similar) written into a multi-tenant system of record. For pull-based sync from third-party APIs, see our design for incremental sync with checkpoints and reconciliation.
Key Takeaways
- An import is a workflow, not a request. Model it as a durable state machine with immutable inputs, so a crash resumes instead of restarting.
- Bind identity to content. Use a tenant-scoped operation key plus a hash of the approved plan. Reject key reuse with a different payload. Allow a deliberate re-upload under a new key.
- Never guess on uncertain input. Reject invalid rows with reason codes, and quarantine ambiguous rows for a human decision.
- Commit in chunks with a ledger written in the same transaction as the data. Recovery then reads the ledger instead of retrying blindly.
- Unknown is its own state. If completion cannot be confirmed, report outcome unknown, then reconcile. Do not report success or failure.
- Privacy has an owner. Raw files, error reports, filenames and import IDs can all contain or reveal personal data. Give each one a retention rule and a deletion path.
- Use go/no-go controls, not a score. Every mandatory control must pass before launch. No total of points certifies readiness.
Why Bulk Imports Break in Production
Key idea: The usual implementation couples file parsing, validation and writing into one loop with one retry policy. Each of those concerns fails differently, so one blanket policy cannot cover them.
Imports concentrate several hard problems into one feature. The payload is large, so the work outlives a request. The payload is user-controlled, so every field is hostile until proven otherwise. The write is repeated thousands of times, so any non-idempotent step multiplies. And the data belongs to a tenant, so a single wrong tenant key is a data breach rather than a bug.
How most teams approach it
The first version is almost always the same. A controller receives the file, parses it with a CSV library, loops, inserts each row, and returns 200 OK with a count. When files grow, the loop moves into a background job wrapped in a retry-on-exception policy. Each step is reasonable. Together they hide the failure modes.
| Common shortcut | What happens in production | Design requirement it violates |
|---|---|---|
| Parse and write in one loop | A bad row at line 80,000 aborts or half-applies the file | Separate validation from commit, and keep per-row outcomes |
| Retry the whole job on any exception | Rows 1 to 87,000 are applied twice | Resumable work with a ledger of what is already applied |
| Deduplicate on file name | Two different files with the same name collide. Or one file under two names runs twice | Identity bound to content and tenant, not to a label |
| Trust the declared Content-Type or extension | Mislabeled or hostile files reach the parser | Treat file metadata as untrusted input |
| Return a count of rows inserted | Rejects, conflicts and skips are invisible | Every row has a recorded outcome, and the totals reconcile |
| Shared staging table without tenant scoping in every query | A bug in one query exposes another tenant's rows | Tenant ID in every key, query and path |
Why CSV makes this harder than it looks
RFC 4180 documents the common CSV format, but it is an informational memo, not an Internet standard. It states that there is no formal specification and that implementations interpret files in many ways. Encodings, delimiters, header presence, quoting and line endings vary by the tool that exported the file. So the same bytes can legitimately parse into different data. A robust import design treats format interpretation as an explicit, confirmed input, not as an assumption.
If you are still deciding how tenants share storage, the isolation model changes how you scope staging and error data. Our guide to multi-tenant database isolation patterns covers the options. Everything below assumes every table carries a tenant identifier and every query filters by it.
A Proposed Import State Machine: Plan First, Then Apply
Key idea: Make the import row itself the unit of work. Persist the state, freeze the inputs, and let workers claim and resume from that record.
The framework below is a proposed design we call the Import Control Model for discussion. It is not a certified standard or a product feature. It rests on four rules.
- The import record is the work. A durable row in your database represents the import, and workers claim from it. This avoids the dual-write gap between "saved a record" and "enqueued a message". If you also publish events, use an outbox written in the same transaction, as described in our outbox pattern guide.
- Inputs are immutable after verification. The stored file, its digest, the column mapping and the import mode are frozen. Any change creates a new import.
- Plan before apply. A dry run parses and validates the file and produces a plan: counts of creates, updates, rejects and ambiguous rows. The user (or an authorized automated policy) approves a plan hash. Apply executes only that approved plan.
- Unknown is a state. If a worker cannot confirm whether a chunk committed, the import moves to outcome_unknown and reconciliation runs. It is never silently marked completed or failed.
States and transitions
| State | Meaning | Terminal? |
|---|---|---|
received | Operation record created. File upload not yet verified | No |
verified | File stored privately, size and digest confirmed, scan passed | No |
planned | Dry run complete, plan hash computed, quarantine list produced | No |
queued | Plan approved. Waiting for a worker to claim it | No |
applying | Claimed under a lease with a fencing token. Chunks are being committed | No |
completed | Every chunk is in the ledger and row outcomes reconcile to the total | Yes |
completed_with_rejects | As above, with a downloadable, access-controlled reject report | Yes |
failed_terminal | Unrecoverable without user action, such as unreadable file or schema mismatch | Yes |
outcome_unknown | Completion could not be confirmed. Reconciliation required | No |
cancelled / expired | User cancelled, or plan approval window lapsed | Yes |
received -> verified -> planned -> queued -> applying -> completed | | | \-> completed_with_rejects | | | \-> outcome_unknown -> (reconcile) -> completed | failed_terminal \-> failed_terminal \-> failed_terminal planned -> expired | cancelledWhy plan-then-apply earns its cost
It adds a step for the user and a dry-run implementation for you. In exchange, it gives you a stable object to approve, audit and replay. It also gives you a natural home for the consequential decisions: whether to overwrite existing records, whether to create new ones, and what to do with ambiguous matches. For large destructive imports, such as bulk overwrite or delete modes, the approval can also integrate with an authorization workflow like the one in our guide to approval-gated actions with tenant-scoped execution. That design is written for AI agents, but the approval pattern applies equally to human-initiated bulk mutations.
Tenant-Safe Identity, Idempotency and Atomic Claiming
Key idea: Idempotency means that the same intent produces one effect. To enforce that, bind a durable, tenant-scoped operation identity to an immutable payload hash, and let the database arbitrate concurrent workers.
General idempotency theory is covered in our production-ready idempotency framework. Here is how it applies to imports.
Three identities, three jobs
| Identity | Scope | Purpose |
|---|---|---|
| Operation key (client-supplied or generated by the upload session) | (tenant_id, operation_key) | Collapses retries of the same user intent into one import |
| Payload hash | Fixed at approval | Digest of the file bytes, column mapping, import mode and plan. Detects a changed payload under a reused key |
| Chunk identity | (tenant_id, import_id, chunk_no) | Ensures a chunk is applied once, even when workers overlap |
Rules that must hold
- Same key, same payload hash: return the existing import and its current state. Do not start a second one.
- Same key, different payload hash: reject with a conflict. Never merge, replace or "update" the stored payload. A browser retry that silently sends an edited file is exactly the case this stops.
- New key, same payload hash: treat it as a legitimate later operation. Users re-import identical files on purpose, for example after fixing a mapping upstream. Allow it, optionally warn about the earlier import, and let row-level guards make the replay safe.
- Tenant in every key. A key that is unique only globally, or only per file name, lets one tenant's retry collide with another's. Remember that identifiers can themselves be sensitive. Import IDs, file names and external references can reveal customers, so keep them out of logs and third-party analytics where you can.
Illustrative schema and create-or-return
CREATE TABLE imports ( tenant_id uuid NOT NULL, import_id uuid NOT NULL, operation_key text NOT NULL, payload_hash bytea NOT NULL, -- immutable after approval state text NOT NULL, lease_owner text, lease_expires_at timestamptz, fencing_token bigint NOT NULL DEFAULT 0, PRIMARY KEY (tenant_id, import_id), UNIQUE (tenant_id, operation_key) ); -- 1) Create, or do nothing if this tenant already used the key INSERT INTO imports (tenant_id, import_id, operation_key, payload_hash, state) VALUES ($1, $2, $3, $4, 'received') ON CONFLICT (tenant_id, operation_key) DO NOTHING; -- 2) Load the winner. Compare against its immutable hash SELECT import_id, payload_hash, state FROM imports WHERE tenant_id = $1 AND operation_key = $3; -- if payload_hash differs from $4: respond 409 ConflictThe comparison in step 2 is safe because payload_hash is never updated. Enforce that with a trigger or by withholding UPDATE privilege on the column. This is a comparison against an immutable value, not a read-compare-then-write on mutable state.
Atomic claim with a lease and fencing token
UPDATE imports SET state = 'applying', lease_owner = $2, lease_expires_at = now() + interval '5 minutes', fencing_token = fencing_token + 1 WHERE tenant_id = $1 AND import_id = $3 AND ( state = 'queued' OR (state = 'applying' AND lease_expires_at < now()) ) RETURNING fencing_token;The single conditional UPDATE is the claim. If it returns no row, another worker owns the import. Two workers cannot both win, because the database serializes the row. A worker that loses its lease may still be running, so every chunk write must present its fencing token and be rejected if a newer token exists. The full reasoning is in our design for worker leases and fencing tokens.
Uncertain Input: Validate, Quarantine and Fail Terminally on Purpose
Key idea: Classify every problem as retryable infrastructure failure, terminal input failure, or ambiguous input needing a human. Only the first class gets automatic retries.
Treat the file as hostile before you treat it as data
The OWASP File Upload Cheat Sheet is explicit that no single technique is enough, and it recommends defense in depth. Points that matter most for import pipelines:
- The client-provided Content-Type is trivially spoofed. Use it for a friendly early check, never as a security control.
- Validate extension against an allowlist, but also validate content. Signature checks alone are easy to bypass.
- Store files under a generated name (such as a UUID) in private storage, not under a user-supplied name or inside a web-served path.
- Enforce size limits, and apply them after decompression if you accept archives. OWASP advises against accepting ZIP files where you can avoid it.
- Authenticate and authorize the uploader before accepting bytes, and scan files where your threat model calls for it.
Also consider CSV formula injection when users later open exports or reject reports in a spreadsheet. The OWASP CSV Injection entry describes how cells beginning with characters such as = can be interpreted as formulas. Neutralize those cells in any CSV your system generates, including reject reports that echo user data.
Make format interpretation explicit
Because CSV has no single master specification, do not guess silently. Detect the encoding and delimiter, show the user a preview of the first rows as parsed, and record the confirmed settings as part of the payload that gets hashed. If the user later disputes a result, you can show exactly how the bytes were interpreted.
Row classification
| Condition | Treatment | Auto-retry? |
|---|---|---|
| Database timeout, worker crash, dependency outage | Retry the chunk. Rely on the ledger and guarded writes | Yes, with backoff and a cap |
| File unreadable, wrong encoding after confirmation, required column missing | failed_terminal with a clear reason code and remediation text | No |
| Row fails validation (bad email, value out of range, unknown enum) | Reject the row with a reason code. Continue with the others | No |
| Ambiguous: date like 03/04/2025 with no declared format, or two existing records match one external reference | Quarantine the row. Ask the user to decide. Never pick one silently | No, waits for a human |
| Reject rate exceeds the tenant's configured abort policy | Pause before apply, and ask for confirmation | No |
Why not auto-retry invalid rows? A retry cannot fix a deterministic validation failure, and it only adds load and noise. Rows that exhaust retries because of infrastructure faults belong in a dead-letter path with an explicit, audited redrive. Our dead letter queue redrive playbook covers that process.
Write a per-row outcome for every row
Persist one outcome per source row, keyed by line number and the import: applied, unchanged, rejected, quarantined or conflict. Without this, "completed" is a claim you cannot verify. With it, you can assert an invariant at the end: applied + unchanged + rejected + quarantined + conflict = total_rows. If the sum does not match, the import is not complete.
Committing Safely: Chunk Ledger, Guarded Writes and Verified Acknowledgment
Key idea: Write the chunk ledger entry in the same transaction as the chunk's data. Then "is this chunk applied?" has one authoritative answer, even after a timeout.
Chunking
Because the staged file is immutable, chunk boundaries are deterministic: chunk n is always the same row range. That is what makes resume possible. Choose a chunk size that keeps a transaction short enough to avoid lock pile-ups and replication lag, yet large enough to amortize overhead. Tune it against your own database and measure. There is no universal number.
Chunk transaction (illustrative pseudo-code)
function applyChunk(tenantId, importId, chunkNo, token, snapshotTime): tx = begin() imp = tx.query('SELECT fencing_token FROM imports WHERE tenant_id=$1 AND import_id=$2 FOR SHARE') if imp.fencing_token != token: tx.rollback(); return STALE_OWNER for row in stagedRows(tenantId, importId, chunkNo): // immutable input result = tx.exec(GUARDED_UPSERT, row, importId, snapshotTime) recordRowOutcome(tx, tenantId, importId, row.line, result) // PRIMARY KEY (tenant_id, import_id, chunk_no) makes double-apply impossible tx.exec('INSERT INTO import_chunks (tenant_id, import_id, chunk_no, counts) VALUES ($1,$2,$3,$4)') tx.commit() // may throw a timeout: the result is AMBIGUOUSIf the ledger insert conflicts, another attempt already committed this chunk, so the transaction rolls back and the worker moves on.
Guarded upsert: conditional mutation on the target
INSERT INTO contacts (tenant_id, external_ref, email, full_name, last_import_id, updated_at) VALUES ($1, $2, $3, $4, $5, now()) ON CONFLICT (tenant_id, external_ref) DO UPDATE SET email = EXCLUDED.email, full_name = EXCLUDED.full_name, last_import_id = EXCLUDED.last_import_id, updated_at = now() WHERE contacts.updated_at <= $6 -- skip rows edited after plan approval AND contacts.last_import_id IS DISTINCT FROM EXCLUDED.last_import_id; -- replay-safe The conditions are evaluated by the database inside the same statement that writes. That is what makes this a true conditional mutation. A pattern that reads the row, compares in application code and then writes is not compare-and-swap, because another writer can slip in between. If a statement affects zero rows, the cause is ambiguous: the row was already applied by this import, or a user edited it after the snapshot. Disambiguate with a follow-up read in the same transaction (last_import_id equals this import means already applied; otherwise record a conflict).
Limitation to disclose: this guard works because the target is a single transactional store you control. If an import also writes to an external API that lacks conditional requests or idempotency keys, you cannot get the same guarantee. In that case, serialize writes per entity, record the outcome as unknown when you cannot confirm it, and reconcile before any retry.
Ambiguous commits: reconcile before you retry
If commit() times out, you do not know whether the chunk landed. Do not blindly re-run it. Read the ledger for (tenant_id, import_id, chunk_no) in a fresh transaction. If the row exists, the chunk is applied. If it does not, a retry is safe here because the guarded upsert and the ledger primary key together prevent a double apply. If the database itself is unreachable, move the import to outcome_unknown and reconcile when it returns. The general principle is the same as in our guide to ambiguous timeouts in consequential API calls.
What "acknowledged" and "completed" must mean
- Upload accepted (HTTP 202): return only after the file is durably stored, its digest verified, and the import record committed. The record is the scheduled work, so there is no separate enqueue step that can fail.
- Completed: every chunk is in the ledger, the row-outcome totals reconcile, and the terminal state commit is durable. Only then emit
import.completedfrom the outbox. - Webhook consumers: if tenants receive completion webhooks, the sender-side design belongs in a pipeline like our outbound webhook delivery architecture.
Is your import path resumable, or just retried?
If a worker crash today would re-run rows or hide rejects, the gap is in the design, not the code review.
Talk to Developers.dev engineers about auditing your import and integration pipelines.
Contact UsWhy This Fails in the Real World: Common Failure Patterns
Key idea: Capable teams still ship these failures. The cause is almost always a missing system property, such as an immutable input, a ledger or an ownership rule, not a careless engineer.
Failure pattern 1: The crash that doubles half the file
Scenario (illustrative): A 200,000-row file is processed in 200 chunks of 1,000 rows. The worker is killed during chunk 88 by a deploy. The job framework retries the whole job, and the insert path has no replay guard. If the first 87 chunks were committed, 87,000 rows are written twice, and the tenant sees duplicate contacts.
Why smart teams still miss it: The job passed every test with small files, where a retry from the start was cheap and nobody noticed duplicates. The retry policy lives in the queue configuration, and the idempotency requirement lives in the data model. Different people own each, and neither sees the other's assumption.
Systemic fix: A chunk ledger committed with the data, a replay-safe guarded write, and a claim that includes a fencing token. Review retry policy and data-model idempotency in the same design review.
Failure pattern 2: Same key, different file
A user uploads a file, notices a typo, edits it and re-uploads. The client reuses the earlier operation key because the retry logic is generic. A system that dedupes on the key alone returns the first import's result and quietly drops the corrected file. A system that dedupes on content alone blocks a legitimate deliberate re-import.
Why smart teams miss it: "Idempotency key" is understood as a transport-level retry feature, not as a binding between intent and payload. Nobody wrote down what "same operation" means.
Systemic fix: Operation key plus payload hash, a 409 on mismatch, and an explicit rule that a new key with identical content is allowed.
Failure pattern 3: Tenant leakage through the side doors
The main import tables are correctly scoped, but the reject report is stored at a path built from the import ID alone, a staging table is shared without a tenant filter in one debug query, or a cache key omits the tenant. Row-level security is useful defense in depth. PostgreSQL row security policies can restrict rows by tenant, though table owners and superusers bypass them unless you configure otherwise. Treat policies as a backstop, not as a substitute for scoped keys everywhere.
Failure pattern 4: Green dashboard, missing rows
The job finishes without exceptions, and the UI says "Import complete". Meanwhile 4% of rows were rejected, and the reject report was never surfaced. Support learns about it from a customer. Systemic fix: "completed" requires reconciled row outcomes, and rejects are first-class output.
Trade-offs of this design
| Design choice | What you gain | What it costs |
|---|---|---|
| Plan-then-apply | Approvable, auditable, replayable plan | Dry-run implementation, extra user step, plan can go stale |
| Immutable staged input | Deterministic chunks and resume | Storage for raw and parsed copies, plus retention work |
| Per-row outcome records | Verifiable completion and rich reject reports | Write amplification and a larger table to purge |
| Guarded upserts | Replay safety and no clobbering of newer edits | More complex SQL, plus conflict handling in the UX |
| Fencing and leases | Safe takeover after a worker stalls | Every write path must check the token |
| Quarantine for ambiguity | No silent wrong guesses | Human effort, so you need a usable review UI |
If you are retrofitting this onto a live system, the schema changes (ledger tables, new columns such as last_import_id) should ship in compatible steps. Our expand-contract design for zero-downtime schema changes describes how.
Privacy Ownership, Go/No-Go Controls and a Lower-Risk Rollout
Key idea: Import pipelines copy personal data into more places than the product's main database. Assign an owner and a retention rule to every copy, and treat the control list below as all-required.
Privacy ownership: every copy needs an owner
| Artifact | Why it is sensitive | Decision to make |
|---|---|---|
| Raw uploaded file | Contains full source data | Retention period, encryption, who may download it |
| Parsed staging rows | A second copy in your database | Delete after completion or after a defined audit window |
| Reject and quarantine reports | Echo user data, often the messiest values | Minimize echoed fields, restrict access, set a short expiry |
| Logs and traces | Row values leak into exception messages | Log line numbers and reason codes, not cell values |
| Identifiers (import ID, file name, external reference) | Can reveal customers or individuals | Keep them out of third-party tools and URLs where feasible |
| Backups and replicas | Retain deleted data for their own cycle | Document the expiry lag, and propagate deletion where required |
Whether your company or your tenant is responsible for particular obligations depends on your contracts, the roles each party plays, and the jurisdictions involved. Those are reader-specific legal questions. Confirm them with counsel instead of assuming. For the engineering side of proving removal across copies, see our design for verified erasure workflows.
Mandatory go/no-go controls
These are all-required checks, not a points system. A pipeline that passes nine of ten still has a hole, and no total certifies production readiness. A "no" on any row blocks launch until it is fixed or an owner records an explicit, time-boxed risk acceptance.
| # | Control | Evidence to show |
|---|---|---|
| 1 | Operation identity is tenant-scoped and bound to an immutable payload hash. Key reuse with a different payload returns a conflict | Test: same key, different file returns 409 |
| 2 | Work is claimed atomically, and stale owners are fenced out on every write | Test: two workers, one wins. A stalled worker's write is rejected |
| 3 | Chunk ledger is written in the same transaction as the data | Kill-test mid-chunk shows no partial chunk and no duplicate |
| 4 | Writes are conditional on the target side. Replay does not double-apply or overwrite newer edits | Replay test, plus a concurrent-edit test |
| 5 | Ambiguous commit triggers reconciliation, not blind retry. Unknown is a distinct state | Fault-injection test on commit timeout |
| 6 | Every row has a recorded outcome, and totals reconcile before "completed" | Invariant check in the completion path |
| 7 | Invalid rows are rejected with reason codes, ambiguous rows are quarantined, and validation failures are not auto-retried | Fixture file covering each class |
| 8 | Upload handling follows allowlist, private storage, size and content checks | Review against the OWASP guidance |
| 9 | Tenant ID is present in every key, path, cache key and query. Cross-tenant tests exist | Automated cross-tenant access test |
| 10 | Each data copy has an owner, a retention rule and a deletion path | Data-flow inventory signed off |
| 11 | Dead-letter and redrive are audited. Alerts exist for stuck and unknown imports | Runbook and alert definitions |
Passing these controls shows that the design has the properties above. It does not certify compliance with any law or standard, and it does not replace load testing against your own data volumes.
A lower-risk rollout path
- Shadow mode: run the new plan stage on real uploads without applying, and compare its counts with the legacy path.
- Small tenants first: enable apply for a few tenants with small files, behind a feature flag.
- Fault drills: kill workers mid-import and cut database connections on purpose, and verify the invariants hold.
- Widen gradually: raise file-size limits only after reconciliation reports stay clean.
2027 Update: AI-assisted mapping does not change the controls
Imports increasingly include AI-suggested column mappings, automated cleanup, or agents that prepare and submit files. Evergreen rule: a suggestion from a model is just another untrusted proposer. Its output must pass through the same plan, approval-hash and guarded-apply path as a human's. The state machine and go/no-go controls above do not change. Only the source of the proposed mapping does. Tools will change, but the invariants (immutable input, bound identity, atomic claim, ledgered commit, reconciled completion) are what keep working.
Conclusion: Five Decisions to Make Before You Ship the Import Button
A bulk import is safe when you can answer, for any file at any moment: what exactly was approved, who owns the work, which chunks are applied, and which rows were rejected. These actions turn that into practice:
- Write down what "same operation" means. Define the tenant-scoped key and the payload hash, and decide how a changed payload under the same key and a deliberate re-upload under a new key are handled.
- Move the work into a durable import record with a lease, fencing token and the state machine above, so a crash resumes instead of restarting.
- Add the chunk ledger and guarded upserts, then run a kill-and-replay test on a realistic file before any customer does.
- Define terminal failure and unknown outcome. Decide which problems auto-retry, which reject, which quarantine, and when the system must say "outcome unknown" and reconcile.
- Assign owners to every data copy (raw file, staging, reject reports, logs) and verify that all eleven go/no-go controls pass.
About the team behind this guide
Developers.dev is an offshore software development and staff augmentation company founded in 2007 and headquartered in Indore, India, with engineering teams serving clients across the USA, EMEA and Australia. The company states that it holds CMMI Level 5, ISO 27001 and SOC 2 credentials. Those apply to company processes and do not vouch for the code samples in this article, which are illustrative and need validation in your environment. This article was reviewed by the Developers.dev Expert Team.
Frequently Asked Questions
What is the safest way to retry a failed bulk import?
Retry at the chunk level, not the file level, and only for infrastructure failures. Persist a chunk ledger in the same transaction as the data, use guarded writes so replays are harmless, and check the ledger before re-running a chunk whose commit result is unknown. Validation failures should be rejected or quarantined, not retried.
How do I stop users from importing the same file twice?
You usually should not block it outright. Bind each operation to a tenant-scoped key and a payload hash. A repeated request with the same key and hash returns the existing import. A new key with identical content is a legitimate later operation. Allow it, optionally warn, and rely on replay-safe row writes. Reusing a key with different content should be rejected as a conflict.
Should invalid rows fail the whole import or be skipped?
It depends on the import mode and the tenant's tolerance. A common approach is a plan stage that reports counts of valid, rejected and ambiguous rows. The user then approves a partial apply or fixes the file. Add an abort policy for a configurable reject rate. Whatever you choose, make the behavior explicit and record every row's outcome.
Can I trust the Content-Type or file extension on an upload?
No. Both are client-controlled. OWASP's file upload guidance recommends defense in depth: allowlisted extensions, content validation, generated storage names, private storage, size limits and authorization checks. Use client metadata only for early, friendly feedback.
Is CSV a standardized format I can rely on?
Not strictly. RFC 4180 documents common practice and registers the text/csv media type, but it is an informational document and notes that there is no single master specification. Treat encoding, delimiter and header handling as explicit, confirmed settings, and include them in the payload you hash.
What does "outcome unknown" mean, and how is it different from failed?
Unknown means completion could not be confirmed, so it is neither success nor a verified failure. This happens when a commit times out or a worker disappears mid-chunk. Reconcile by reading the authoritative ledger before deciding to retry, mark the import completed, or mark it failed.
Do I need a queue like Kafka or SQS for imports?
Not necessarily. A durable import table with atomic claiming can serve as the work queue, which avoids a dual-write between database and broker. If you do use a broker, make sure the scheduling record and the import record are written atomically (for example with an outbox), or that the gap is explicitly recoverable before you acknowledge the upload.
How should we handle personal data in reject reports and logs?
Minimize it. Log line numbers and reason codes instead of cell values, limit who can download reject reports, expire them quickly, and include them in your deletion workflow. Your specific legal obligations depend on your contracts and jurisdictions, so confirm them with counsel.
Importing customer data is the one feature you cannot afford to get 'mostly right'.
Duplicate rows, silent rejects and cross-tenant leaks surface in support tickets first and in post-mortems later. A design review now costs far less.
Developers.dev engineering teams can review your import design, harden your integration pipelines, or extend your team with engineers experienced in data and API work.
Contact UsRelated services: API integration services, data engineering and analytics, and staff augmentation.
Data Engineering Analytics
This guide is designed for engineering leaders who want to plan a practical implementation. Use the related Developers.dev path to compare delivery options, implementation fit, risk, and practical next steps.
Reviewed by the Experts team
This guide is reviewed for clarity, technical and operational relevance, service alignment, and a useful next step. Verified by our SEO team for clear search presentation.
Reviewed by the Experts team. Verified by our SEO team. Validate legal, security, data, budget, and operational requirements with the relevant stakeholders before rollout.

