Reporting Row Level Errors Back to the User of a CSV Import
Report CSV import errors users can act on: line, column, value and rule, all problems per row, and a file of rejected rows to fix and re-upload.

The import has failed on 31 rows out of 4,000. What does the user see? If the answer is a banner saying "Import failed", they have no way to proceed. If it is a list of 31 messages written in your schema's vocabulary, they can proceed slowly. If it is a file of exactly the 31 rows, each with a plain-English reason in the last column, they fix it in two minutes. The difference between those three is a small amount of design, and it decides whether the import feature gets used. This article describes an error report that works, with the real output of one: what each line must carry, how to number the rows so that the user can find them, how to handle several errors on one row, and how to serve a corrected file back. It is a stage of the pipeline in CSV import and export features in applications, and it takes its errors from the validation described in previewing a CSV upload before committing.
What each error line must carry
An error is a sentence with five parts, and a report that omits any of them is harder to use. The line locates the row. The column names the field. The value shows what was found. The rule says what is wrong. And the report as a whole must say what would be accepted. The report from the test import:
line,column,value,problem
4,plan,enterprise,"plan must be one of: free, pro, team"
4,seats,,"seats must be a whole number of 1 or more"
5,email,not-an-email,"not a valid email address"
6,plan,gold,"plan must be one of: free, pro, team"Each of those lines answers "where, what, why". Line 4 has two entries, since row 4 had two problems, a plan that is not offered and an empty seat count. Showing both at once matters: a user who is told only about the plan fixes it, re-uploads, and then learns about the seats. With one error reported per row, a file with three problems per row takes four uploads to pass. Run all the rules on every row, collect the failures and report them together.
The messages follow a rule: say what is accepted, not only what is wrong. "plan must be one of: free, pro, team" is a fix, and "invalid plan" is a verdict. For numbers say the range and the form, "a whole number of 1 or more". For dates, show the format with an example, "use YYYY-MM-DD, for example 2026-03-04". Keep the tone neutral: the file is data, and the message is a description of it.
Numbering the rows
The line number is the thing users use to find the cell, so it has to match what they see. A spreadsheet shows row 1 as the header and row 2 as the first data row, and a plain text editor shows the same line numbers, as long as no field contains a line break. The test report uses the physical line, header as line 1: the customer with a bad email is on line 5 of the file, and row 5 in Excel. That is the convention to follow, and to state in a sentence at the top of the report: "Line numbers count the header as line 1."
A subtlety arises with quoted fields that contain line breaks. A CSV parser counts records, and a text editor counts lines, and the two differ after the first multi-line field. If the file might have such fields, report the line where the record starts (the parser can tell you, for example through reader.line_num in Python) and not the index of the record, or the user will look in the wrong place in a text editor. In a spreadsheet, the row number equals the record number, since the spreadsheet already parsed the file, so if your users are spreadsheet people, record numbers are right and line numbers are wrong. Pick the one that matches your users and say which it is (a CRM export with multi line notes shows how the two diverge).
Use the user's header for the column, not your field name. If the file says Full name and your schema says display_name, the report should say Full name, because that is the word on the user's screen. Keep the mapping from the mapping stage and use it when you format the errors.
Give them the rows back
A list of messages tells the user what to fix. A file lets them fix it. The rejected rows, exactly as they were uploaded, plus an extra column error containing the reason, is the format that works best, because the user opens it in the spreadsheet they already use, edits the cells in place, deletes the error column and uploads it again:
Email,Full name,Plan,Seats,error
[email protected],Cy Young,enterprise,,"plan must be one of: free, pro, team; seats must be a whole number of 1 or more"
not-an-email,Di Park,pro,3,"not a valid email address"
[email protected],Ed Fox,gold,2,"plan must be one of: free, pro, team"The three rows are the three rejected rows from the six-row file, with their original values and the reasons joined with a semicolon. The user fixes the plan on two rows, supplies seats on one, corrects the email on another, and uploads this file, now three rows long, on its own. The import then reports "3 updated or created" with no need to re-send the other three that already succeeded. If your import is idempotent, resending the whole corrected file would also work, but the smaller file is faster to check and does not risk overwriting rows that someone changed in the meantime (idempotent CSV imports that are safe to run twice).
Write the file with the same header as the original, in the original column order, with the original text of each value, not the parsed one. A zip code the parser read as 2134 must come back as 02134 if that is what the user wrote, which means keeping the raw line or the raw field values and not the parsed ones. And write it through a real CSV writer with the usual quoting, and escape text that a spreadsheet would evaluate as a formula, since a value the user typed is going into a file they will open (escaping formula characters in a CSV export).
Many errors: group and cap
When 4,000 rows produce 2,400 errors, the list is not a report. Count and group. "2,310 rows: plan is not one of free, pro, team" followed by the five most common offending values (Pro , PRO, enterprise, trial, premium) tells the user that the problem is capitalization and trailing spaces, which they can fix by find and replace in their spreadsheet, or that they used plan names from another system. A table sorted by count, with an example row for each, makes a bulk pattern visible in seconds. Cap what is shown on screen at the first 100 grouped lines, and offer the full list as a download.
Also decide when to stop. For a file in which the first 50 rows are all wrong in the same way, it is likely that something systematic is off: the wrong delimiter, the wrong file, the header in the wrong place. Rather than validate 100,000 more rows and produce a report of 100,000 errors, stop after a set number of errors (say 1,000) and say so: "Stopped after 1,000 errors. The first problem is on line 2." The user fixes the systematic issue and uploads again. A cap also protects your server from a file designed to produce a huge report.
Warnings are not errors
Not everything a validation finds should stop a row. A name in capitals, a phone number with spaces, a country written as UK where the system prefers GB can be normalized silently or with a note. Separate errors, which block the row, from warnings, which change the row or flag it for a look. Report warnings in a second section ("14 rows were adjusted: phone numbers had spaces removed") so that the user knows what the system did, and do not mix them with failures. A report that cries wolf on everything loses its force.
For each rule, decide whether it is an error or a warning once, document it and test it. The distinction is a product decision, and it should be the same in the preview, the commit and the report.
When errors are too many to list
If most of a file fails, the problem is not the rows. Detect it and switch the message: "This file does not seem to match the template: 3 of 4 required columns were not found." The mapping stage should have caught a header problem before row validation started (designing a CSV import screen users can succeed with), and the row validation should refuse to run on a file whose structure is wrong. An error report of 40,000 lines that says the same thing is a symptom of a missing structural check.
Also treat privacy with care. The error file contains rows of the user's own data, which is fine, but an on-screen message that includes a value is an echo of user input, so escape it when you render it (an email field containing a script tag should not run in your error page), and do not log values that are personal data into a general application log (handling CSV files full of customer data under GDPR). The CSV format for the report file follows RFC 4180, and PHP's fputcsv documentation covers the writer used for it.


