How to import CSV files in Ruby on Rails (with Hotwire and Turbo)

Add a complete CSV and Excel import widget to your Ruby on Rails app — Hotwire, Turbo, or plain ERB — with AI column mapping, in-browser validation, and an `insert_all` handler that scales.

Rails apps have been doing CSV import the same way since 2009: a <%= file_field_tag :file %>, a controller action that calls CSV.parse, and a model that creates records one by one. It works — until your customers start uploading 50K rows from an Excel file with merged cells and European date formats.

This guide shows the modern alternative: a five-minute integration of Rowslint, a client-side CSV and Excel importer, with a Rails app. AI column matching, in-browser validation, multi-format parsing, and a clean insert_all path on the server.

What is Rowslint

Rowslint is an embedded CSV and Excel importer for web apps. From a Rails ERB view (with or without Stimulus), you call launchRowslint(), your users get a polished import flow, and you receive a JSON array of validated, typed rows in a Rails controller.

The importer parses files entirely in the browser. Rails doesn’t need to handle file uploads, parse CSVs, or worry about Excel binary formats — only the cleaned rows arrive.

Step 1: Create a Rowslint account and template

Sign up for the free tier. In the dashboard:

  1. Create a template (e.g. customers_v1).
  2. Add columns matching your customers table.
  3. Set types and validators.
  4. Save and copy the template key + your organization API key.

Template setup guide →

Step 2: Pin Rowslint in Importmaps

Modern Rails (7+) ships with Importmaps. Pin Rowslint:

bin/importmap pin @rowslint/importer-js

This adds the package to config/importmap.rb and downloads it to vendor/javascript/.

Expose your API key from Rails to the frontend via a <meta> tag in your layout:

<%# app/views/layouts/application.html.erb %>
<meta name="rowslint-api-key" content="<%= Rails.application.credentials.rowslint_api_key %>">

Store the key in Rails.application.credentials:

EDITOR=nvim bin/rails credentials:edit
# Add: rowslint_api_key: org_pk_live_xxxxxxxxxxxxx

Step 3: Build a Stimulus controller

app/javascript/controllers/customer_import_controller.js:

import { Controller } from '@hotwired/stimulus';
import { launchRowslint } from '@rowslint/importer-js';

export default class extends Controller {
  static values = { templateKey: String, endpoint: String };

  open() {
    const apiKey = document.querySelector('meta[name="rowslint-api-key"]').content;
    const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

    launchRowslint({
      apiKey,
      config: { templateKey: this.templateKeyValue },
      onImport: async (result) => {
        if (result.status !== 'success') return;
        const response = await fetch(this.endpointValue, {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Accept': 'text/vnd.turbo-stream.html',
            'X-CSRF-Token': csrfToken,
          },
          body: JSON.stringify({ rows: result.data }),
        });
        if (response.ok) {
          const html = await response.text();
          Turbo.renderStreamMessage(html);
        }
      },
    });
  }
}

Register it (Rails auto-registers if you used the Stimulus generator):

bin/rails generate stimulus customer_import

Step 4: Wire the controller to a button in ERB

app/views/customers/index.html.erb:

<header>
  <h1>Customers</h1>
  <%= button_tag 'Import customers',
      data: {
        controller: 'customer-import',
        action: 'click->customer-import#open',
        customer_import_template_key_value: 'customers_v1',
        customer_import_endpoint_value: bulk_import_customers_path
      } %>
</header>

<turbo-frame id="customers-list">
  <%= render @customers %>
</turbo-frame>

Step 5: Receive rows in a Rails controller

config/routes.rb:

resources :customers do
  collection do
    post :bulk_import
  end
end

app/controllers/customers_controller.rb:

class CustomersController < ApplicationController
  before_action :authenticate_user!

  def bulk_import
    rows = bulk_import_params[:rows]
    return head :unprocessable_entity if rows.blank? || rows.size > 50_000

    inserted = 0
    Customer.transaction do
      rows.each_slice(500) do |batch|
        records = batch.map do |r|
          {
            email: r[:email],
            name: r[:name],
            plan: r[:plan],
            created_at: Time.parse(r[:created_at]),
            updated_at: Time.current,
          }
        end
        Customer.insert_all(records, unique_by: :email)
        inserted += batch.size
      end
    end

    @customers = Customer.recent.limit(50)

    respond_to do |format|
      format.turbo_stream do
        render turbo_stream: turbo_stream.replace(
          'customers-list',
          partial: 'customers/list',
          locals: { customers: @customers, last_imported: inserted }
        )
      end
      format.json { render json: { inserted: } }
    end
  end

  private

  def bulk_import_params
    params.permit(rows: %i[email name plan created_at])
  end
end

Three things this controller gets right:

  1. authenticate_user! + Rails CSRF middleware secure the endpoint by default.
  2. insert_all with unique_by: :email is idempotent — running the same import twice doesn’t crash on duplicates.
  3. Turbo Stream response updates the customer list reactively without a full page reload.

Step 6: Add async validation against Active Record

Configure async validators in your Rowslint template (dashboard) pointing at a Rails controller:

# config/routes.rb
resources :validations, only: [] do
  collection do
    post :email
  end
end

# app/controllers/validations_controller.rb
class ValidationsController < ApplicationController
  before_action :authenticate_user!

  def email
    email = params[:value]
    exists = Customer.where(email: email).exists?
    render json: {
      valid: !exists,
      message: exists ? 'A customer with this email already exists' : nil,
    }
  end
end

Users see inline errors as they map columns. They never re-upload a file.

Step 7: Render the imported list as a partial

app/views/customers/_list.html.erb:

<% if local_assigns[:last_imported] %>
  <div class="flash flash-success" role="status">
    Imported <%= last_imported %> customers.
  </div>
<% end %>

<table>
  <% customers.each do |customer| %>
    <tr>
      <td><%= customer.name %></td>
      <td><%= customer.email %></td>
      <td><%= customer.plan %></td>
    </tr>
  <% end %>
</table>

The Turbo Stream from the controller swaps this in with no JS-flash and no full reload — peak Hotwire DX.

Why Rails teams pick Rowslint

  • No roo / creek dependency. Excel parsing in Ruby pulls in 30+ MB of dependencies, has known XXE vulnerabilities, and is slow. Rowslint parses Excel in the browser sandbox.
  • No background job for 95% of imports. A 50K-row CSV is parsed and validated in the user’s browser before it hits Rails. Sidekiq isn’t needed.
  • No file upload churn. No ActiveStorage blobs, no tempfile cleanup, no multipart/form-data handling. Just JSON.

Compared to other Rails CSV tools

FeatureRowslintroo + Active Recordactiverecord-importsmarter_csv
Drop-in customer-facing UI
AI column matching
Excel (XLSX) support✓ (heavy)n/a
Async validation during mapping
Server CPU per importlowhighmediummedium
Setup time< 5 min~1 week~2 days~3 days

activerecord-import and smarter_csv are still great for rake tasks and internal data pipelines. Rowslint is the right tool for customer-facing import.

Production-ready checklist

  • authenticate_user! and CSRF on the bulk endpoint
  • insert_all / upsert_all for fast, idempotent inserts
  • each_slice(500) to keep batches under DB limits
  • Wrap in transaction for atomicity
  • Turbo Stream response for reactive updates
  • Async validators wired for uniqueness checks
  • Skip Active Record callbacks on bulk paths if you don’t need them

Conclusion

You don’t need to roll your own Rails CSV importer in 2026. With Rowslint, you get AI column matching, Excel support, async validators, and a polished mapping UI — wired into your Rails app via Importmaps and Stimulus in five minutes.

Start with the free tier and ship a modern import flow today. See the JavaScript SDK reference for the full API.

Frequently asked questions

What is the best way to import CSV files in Ruby on Rails?
For customer-facing import flows, the most maintainable approach is a client-side importer like Rowslint that hands cleaned, validated rows to a Rails controller. This avoids file upload endpoints, server-side `CSV.parse`, and the operational burden of background jobs for what is fundamentally a UI problem. For internal rake tasks, Ruby's standard library `CSV` and `roo` for Excel remain solid choices.
How do I integrate Rowslint with Rails Importmaps?
Add `@rowslint/importer-js` as a pinned import in `config/importmap.rb`, then `import` it from a Stimulus controller. Wire the controller to a button in your ERB view, and POST validated rows to a Rails controller via `Turbo.fetch` or `fetch` with the CSRF token. Setup takes five minutes.
Does Rowslint work with Hotwire and Turbo?
Yes. After import, return a Turbo Stream response from your controller to update the page reactively — no full reload required. The article shows the full pattern with Stimulus, Turbo Streams, and `insert_all` for bulk persistence.
How do I bulk insert imported rows in Rails?
Use `Customer.insert_all(rows, returning: %i[id email])` for fast inserts that bypass Active Record callbacks, or `Customer.upsert_all` for idempotent inserts on conflict. Always batch in chunks of 500–1000 rows to avoid `prepared_statements` issues and hit the `limit_for_insert_all`.
Can I validate imported rows against my Rails models?
Yes. Configure async validators in your Rowslint template that POST to a Rails controller returning `{valid: bool, message: string}`. Users see inline errors during column mapping for uniqueness checks, foreign keys, and any custom Active Record validation.
Should I use Rowslint or a Sidekiq-based CSV import?
Different jobs. Rowslint is for synchronous, customer-facing UIs where the user wants immediate feedback. Sidekiq-based imports make sense for very large files (>2 GB) or complex post-import processing (image generation, email blasts). Many Rails apps use Rowslint for the upload UX and Sidekiq for the post-import async work.
ship it this sprint

Your users have a spreadsheet.
Let's give them somewhere to put it.

14-day free trial · No credit card required · Cancel anytime