CSV import error recovery: from silent failures to user-friendly correction | Tapix                  [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev) [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)

   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

  [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

   ![Tapix](https://tapix.dev/img/tapix-logo-light.svg)

 Best PracticesCSV import error recovery: from silent failures to user-friendly correction
===========================================================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Best Practices ](https://tapix.dev/blog/category/best-practices)

CSV import error recovery: from silent failures to user-friendly correction
===========================================================================

 Manch Minasyan ·  June 5, 2026  · 10 min read

 Every CSV import has errors. The question that determines your user's experience is not whether errors happen, but what your system does when they do. The range of possible answers spans from genuinely harmful to genuinely useful, and most Laravel applications sit somewhere on the harmful end without realizing it.

This post walks through the error recovery spectrum, explains why inline correction is the only approach that works for non-technical users, and shows the data model that makes it possible: immutable raw data, a corrections JSON overlay, per-field skip tracking, and a failed row safety net for runtime exceptions that slip past validation.

[\#](#the-error-recovery-spectrum "Permalink")The error recovery spectrum
-------------------------------------------------------------------------

There are four distinct approaches to handling bad CSV data. They sit on a spectrum from worst to best, measured by one thing: how much data does the user lose?

**Silent failure** is the bottom of the spectrum. The import runs, bad rows are quietly dropped, and the user gets a success message. "Imported 8,247 of 10,000 rows." Where did the other 1,753 go? Nobody knows. Nobody notices until three weeks later when someone asks why the Acme Corp record is missing. This is not error handling. It is data loss with a green checkmark on top.

**Log file export** is a step up. The import finishes, bad rows are collected into a downloadable CSV or written to a log, and the user gets a summary. "1,753 rows failed. Download the error report." The user now knows what failed. But fixing it means opening the CSV in a spreadsheet editor, hunting for rows by line number, making corrections blind to your application's validation rules, and re-uploading. The second upload has no memory of the column mapping, relationship resolution, or match configuration from the first pass. Context is lost. For files with relationship columns ("Company", "Department"), the re-upload is effectively starting from scratch.

**CSV download of failed rows** is the most common pattern in Laravel packages, including Filament's built-in Import Action. It is better than a log file because the download contains only the failed rows, not the entire file. But the fundamental problem remains: the user leaves your application to fix data in a tool that has no knowledge of your validation rules, your database schema, or your relationship model. And between the first import and the re-upload, database state may have changed. A company name that matched on the first pass might not match anymore if someone edited the record.

**Inline correction** is the top of the spectrum. Errors are shown inside your application, in context, with the original value visible and an input field for the correction. The user fixes the value, the system re-validates immediately, and the correction is stored alongside the original without modifying it. No context switching. No re-upload. No lost mapping configuration. No stale database state.

The gap between "CSV download" and "inline correction" looks small on paper. In practice, it is the difference between a 15-minute frustration loop and a 2-minute review step.

[\#](#why-inline-correction-matters-for-non-technical-users "Permalink")Why inline correction matters for non-technical users
-----------------------------------------------------------------------------------------------------------------------------

Developers underestimate how hostile a "download failed rows CSV" workflow is for someone who is not a developer. The person uploading the CSV is usually an operations manager, a data entry specialist, or an account manager. They know their data. They do not know your database schema.

When you hand them a CSV of failed rows with error messages like "The salary field must be a number" or "The hired\_at field does not match the format Y-m-d," they have to translate between your application's internal language and the spreadsheet in front of them. Which column is "salary"? They mapped it from a column called "Annual Compensation" in their original file. What is "Y-m-d"? They entered dates as "15/04/2026" because that is how dates work in their country.

Inline correction eliminates this translation layer entirely. The user sees the original value they typed, the error message explaining what is wrong, and an input field where they type the corrected value. The system re-validates on the spot. Green means fixed. Red means try again. No spreadsheet. No re-upload. No guessing.

For relationship fields -- a "Company" column that needs to match existing database records -- inline correction is even more important. The review UI can show the user which companies matched and which did not, and let them pick from existing records or type a corrected name. A CSV download cannot do this. The user would need to know the exact spelling of every company name in your database to fix the CSV by hand.

[\#](#the-correction-data-model "Permalink")The correction data model
---------------------------------------------------------------------

The inline correction flow depends on a specific data model: raw data is immutable, corrections are stored as a separate overlay, and the system merges them at execution time. This separation is the architectural decision that makes everything else work.

### [\#](#raw-data-never-changes "Permalink")Raw data never changes

When a CSV is uploaded, every row is parsed and stored in the database with its original values in a `raw_data` JSON column. This column is never modified after initial insert. No matter what the user corrects, edits, or skips, the original CSV values are always recoverable.

This matters for auditability. If a user corrects a salary from "$1.234,56" to "1234.56", you can trace exactly what the original file contained and what the user changed. It also matters for undo -- the user can revert a correction at any time and the original value reappears.

### [\#](#corrections-as-a-json-overlay "Permalink")Corrections as a JSON overlay

When the user types a corrected value, it is stored in a separate `corrections` JSON column on the same row. The keys are column identifiers, the values are the corrected data:

```
// ImportRow model -- simplified view of the correction flow
// raw_data:    {"email": "not-an-email", "salary": "$1.234,56"}
// corrections: {"email": "john@acme.com"}
// validation:  {"salary": "The salary field must be a number."}

public function getFinalValue(string $column): mixed
{
    if ($this->isValueSkipped($column)) {
        return null;
    }

    if ($this->hasValidationError($column)) {
        return null;
    }

    if ($this->corrections?->has($column)) {
        return $this->corrections->get($column);
    }

    return $this->raw_data->get($column);
}

```

The `getFinalValue` method encodes the priority chain: skipped fields return null, unresolved errors return null, corrections take precedence over raw data, and raw data is the fallback. This method is called during execution for every field of every row. It is the single point where raw data and corrections merge.

The `getFinalData` method returns the full merged row for execution:

```
public function getFinalData(): array
{
    return $this->raw_data->merge($this->corrections ?? [])->all();
}

```

This is a shallow merge. Corrections overwrite raw values for the columns they cover. Columns without corrections pass through unchanged.

### [\#](#re-validation-on-correction "Permalink")Re-validation on correction

When the user submits a corrected value, the system does not blindly accept it. The correction is validated against the same rules that flagged the original value. If the correction passes, the validation error for that column is cleared. If the correction is also invalid, the error message updates to reflect the new problem.

This happens in the review step's `updateMappedValue` method. The flow is: receive the new value, validate it against the column's type and rules, store the correction in the `corrections` JSON, and update the `validation` JSON to either clear the error or set a new one. The user sees the result immediately -- the cell turns green or stays red with an updated message.

The critical detail: corrections are applied to all rows sharing the same raw value. If 200 rows have the same misspelled company name, correcting it once fixes all 200. The review UI groups by unique value and shows a count, so the user knows the impact of each correction.

[\#](#skipping-values-not-blank-not-corrected-deliberately-ignored "Permalink")Skipping values: not blank, not corrected, deliberately ignored
----------------------------------------------------------------------------------------------------------------------------------------------

Sometimes the right answer for a bad value is neither correction nor failure. The user looks at a row where the phone number field contains "TBD" and decides: skip this field for this row, import everything else. This is different from a blank value (which might be valid or might trigger a required validation error) and different from a correction (which replaces the original). It is an explicit decision to exclude one field from one row's import.

The `ImportRow` model tracks this with a `skipped` JSON column. Keys are column identifiers, values are boolean `true`:

```
public function isValueSkipped(string $column): bool
{
    return $this->skipped?->has($column) ?? false;
}

```

When a value is skipped, three things happen. First, the `getFinalValue` method returns null for that column, so the execution pipeline treats it as absent. Second, any existing correction for that column is removed -- skip and correct are mutually exclusive states. Third, the validation error is preserved in storage but the row is excluded from the "needs review" filter. The user can see that the value was skipped, but it no longer blocks the import from proceeding.

The review UI exposes this through filter tabs. "Needs Review" shows rows with unresolved validation errors. "Modified" shows rows with corrections. "Skipped" shows rows where the user deliberately ignored a field. These filters are computed per column, so the user can quickly assess the state of each mapped field:

FilterMeaningAllEvery unique value for this columnNeeds ReviewHas a validation error, not skippedModifiedHas a correction, not skippedSkippedUser chose to ignore this fieldThe unskip action reverses the decision. The original validation error resurfaces, and the value returns to the "needs review" state. No data is lost at any point in this cycle.

[\#](#failed-rows-after-execution-the-runtime-safety-net "Permalink")Failed rows after execution: the runtime safety net
------------------------------------------------------------------------------------------------------------------------

Validation catches most problems before execution begins. But some errors only surface at runtime -- a unique constraint violation that the validation layer could not predict, a database timeout on a particularly large row, or an exception thrown by a lifecycle hook in the importer's `beforeSave` or `afterSave` method.

These runtime failures produce `FailedImportRow` records. A unique constraint violation, for example, can slip past the validation stage entirely -- [Intra-import deduplication](/blog/intra-import-deduplication) covers the proactive approach to preventing these before execution starts. When the execution job catches an exception while processing a row, it records the row number, the raw data, and the error message (truncated to 500 characters to prevent storage bloat from stack traces):

```
// Inside ExecuteImportJob -- recording runtime failures
try {
    // ... create or update the model ...
    $importer->afterSave($record, $context);
} catch (\Throwable $e) {
    $results['failed']++;
    $this->recordFailedRow($row->row_number, $row->raw_data->all(), $e);
    report($e);
}

```

Failed rows are flushed to the `FailedImportRow` table in batches of 100 at the end of each chunk, and again if the job itself fails. This ensures that even if the queue worker crashes mid-import, the failed rows recorded up to that point are persisted. This is one of the runtime failure modes that [The hidden cost of building your own CSV importer](/blog/hidden-cost-building-csv-importer) identifies as an iceberg layer -- most DIY importers write failed row records inconsistently or not at all.

The `FailedImportRow` model is separate from `ImportRow` by design. Import rows are the working state of the import -- they exist during the wizard flow and are cleaned up afterward. Failed import rows are a diagnostic record -- they persist after the import completes so that developers and users can investigate what went wrong.

### [\#](#auto-pruning "Permalink")Auto-pruning

Failed rows do not accumulate forever. The `FailedImportRow` model uses Laravel's `MassPrunable` trait with a configurable retention period:

```
class FailedImportRow extends Model
{
    use MassPrunable;

    public function prunable(): Builder
    {
        $days = config('tapix.cleanup_after_days', 30);

        return self::query()->where('created_at', '
