ruby 100 lines · 3 tabs

Streaming CSV Product Import With Per-Row Validation and Error Reporting in Rails

Shared by codesnips Sep 2026
3 tabs
class ImportResult
  RowError = Struct.new(:line, :messages)

  attr_reader :imported, :row_errors

  def initialize
    @imported = 0
    @row_errors = []
  end

  def record_success
    @imported += 1
  end

  def record_failure(line, messages)
    @row_errors << RowError.new(line, Array(messages))
  end

  def failed
    @row_errors.size
  end

  def success?
    @row_errors.empty?
  end

  def summary
    "Imported #{imported} products, #{failed} failed."
  end
end
3 files · ruby Explain with highlit

This snippet shows the common Rails pattern of wrapping a CSV import in a service object so the controller stays thin and the messy details — parsing, per-row validation, and error accumulation — live in one testable place.

In ImportResult, the outcome of the run is modeled as an explicit value object rather than a raw boolean. It tallies imported and failed counts and keeps a row_errors list of small structs, each carrying the offending line number and its messages. Returning a result object instead of raising means the caller can render a rich summary; success? merely asks whether any rows failed. This avoids the classic trap of an import that silently swallows bad rows or aborts the whole file on the first mistake.

The heart of the work is ProductCsvImporter. It reads with CSV.foreach and headers: true so the file is streamed row by row rather than loaded whole into memory, which matters for large uploads. Each row is converted to a plain attributes hash by attributes_for, then handed to Product.new for normal ActiveRecord validation. When save fails, the record's errors.full_messages are folded into the result together with csv.lineno, giving humans an actionable line reference. The enumerate index is + 2 because the header consumes line one and each_with_index starts at zero.

A key design choice is the transaction with requested_by: isolation: each row saves independently, so one bad row never rolls back the good ones, yet the whole run is still wrapped so an unexpected exception leaves the table clean. String#strip guards against stray whitespace from spreadsheet exports, and BigDecimal conversion keeps prices exact.

In ProductsController, the import action reads the uploaded file from params, streams its read into the importer, and stores the returned result. Because the service returns data instead of throwing, the controller can branch on result.success? to flash a clean message or re-render with the detailed row_errors. This separation makes the importer trivial to unit test without HTTP, reusable from a rake task or a background job, and keeps validation logic out of the view layer.


Related snips

Share this code

Here's the card — post it anywhere.

Streaming CSV Product Import With Per-Row Validation and Error Reporting in Rails — share card
Link copied