ruby 102 lines · 3 tabs

Bulk-Insert a Product CSV in Rails with a Service Object and upsert_all

Shared by codesnips Aug 2026
3 tabs
require "csv"

class ProductImporter
  BATCH_SIZE = 500

  attr_reader :errors, :inserted

  def initialize(io)
    @io = io
    @buffer = []
    @errors = []
    @inserted = 0
  end

  def call
    CSV.new(@io, headers: true).each.with_index(2) do |row, line|
      attrs = build_attributes(row)
      @buffer << attrs if attrs
    rescue KeyError, ArgumentError => e
      @errors << { line: line, message: e.message }
    ensure
      flush_batch if @buffer.size >= BATCH_SIZE
    end

    flush_batch
    self
  end

  private

  def build_attributes(row)
    sku = row["sku"].to_s.strip.presence
    raise ArgumentError, "missing sku" unless sku

    {
      sku: sku,
      name: row["name"].to_s.strip,
      price_cents: Integer(Float(row["price"].to_s) * 100),
      currency: row["currency"].presence || "USD",
      updated_at: Time.current
    }
  end

  def flush_batch
    return if @buffer.empty?

    result = Product.upsert_all(
      @buffer,
      unique_by: :sku,
      update_only: %i[name price_cents currency updated_at]
    )
    @inserted += result.rows.size
    @buffer.clear
  end
end
3 files · ruby Explain with highlit

This snippet shows how a CSV product import is structured in Rails so that thousands of rows land in the database in a handful of round trips instead of one INSERT per row. The work is split across a service object that does the heavy lifting, a background job that runs it off the request cycle, and a thin controller that accepts the upload and hands it off.

In ProductImporter service, the CSV is parsed with the standard library CSV class using headers: true so each row behaves like a hash keyed by column name. Rows are accumulated into an in-memory buffer and flushed in fixed-size batches via flush_batch. The flush uses ActiveRecord's upsert_all, which compiles a single multi-row INSERT ... ON CONFLICT statement. This is what makes the import fast: it bypasses model instantiation and validations, so callbacks never fire and each batch is one SQL statement. The trade-off is deliberate — because validations are skipped, the service normalizes and guards data itself in build_attributes, coercing the price and rejecting rows without a sku.

Idempotency comes from unique_by: :sku combined with on_duplicate update logic. Re-running the same file updates existing products by their natural key rather than creating duplicates, which matters because imports get retried. The updated_at timestamp is set explicitly since upsert_all does not touch timestamps automatically. Errors are collected per-row into @errors instead of aborting the whole run, so one malformed line does not discard an otherwise good file.

In ProductImportJob, the service is invoked inside a transaction and the resulting summary is persisted back to an ImportRecord, giving the user a durable status and error report to poll. Wrapping the batches in one transaction means a mid-import failure rolls back cleanly. ActiveJob retries handle transient database hiccups.

In ImportsController, the uploaded file is streamed to storage and only its identifier is passed to the job — never the raw file through the queue, which keeps job payloads small and serializable. The controller responds with 202 Accepted, the correct status for work that completes asynchronously. Together these files show the common Rails pattern of a fast, validation-light bulk path fronted by an explicit service and kept off the web thread.


Related snips

Share this code

Here's the card — post it anywhere.

Bulk-Insert a Product CSV in Rails with a Service Object and upsert_all — share card
Link copied