require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
def initialize(scope, batch_size: 1_000)
@scope = scope
@batch_size = batch_size
end
def each
yield CSV.generate_line(HEADERS)
@scope.in_batches(of: @batch_size) do |batch|
batch.pluck(:id, :full_name, :email, :created_at, :plan).each do |row|
yield CSV.generate_line(row)
end
end
rescue StandardError => e
Rails.logger.error("[PeopleCsvStream] aborted: #{e.class}: #{e.message}")
yield "# export truncated due to an internal error\n"
ensure
ActiveRecord::Base.clear_active_connections!
end
end
class PeopleController < ApplicationController
def export
scope = People
.where(account_id: current_account.id)
.order(:id)
stream = PeopleCsvStream.new(scope)
filename = "people-#{Time.current.strftime('%Y%m%d-%H%M%S')}.csv"
response.headers["Content-Type"] = "text/csv; charset=utf-8"
response.headers["Content-Disposition"] =
%(attachment; filename="#{filename}")
response.headers["Last-Modified"] = Time.current.httpdate
response.headers["Cache-Control"] = "no-cache, no-store"
response.headers["X-Accel-Buffering"] = "no"
self.response_body = stream
end
end
Rails.application.routes.draw do
resources :people, only: [:index] do
collection do
get :export, defaults: { format: :csv }
end
end
end
Exporting a large table to CSV is a classic place where a naive Rails action falls over: building the whole file in memory (or even a String) means the process balloons and the request times out once the row count grows. This snippet shows the streaming approach, where rows are pulled in batches and written to the response body incrementally so memory stays flat regardless of table size.
The core of the technique lives in PeopleCsvStream, a plain enumerable object that yields CSV lines lazily. It uses find_each under the hood via in_batches, keeping only a batch's worth of records resident at once. Because it responds to each, it can be handed straight to Rack as a streaming body. Each yielded chunk is a fully-formed CSV line built with the stdlib CSV library, and the header row is emitted first so the file is valid even if the client disconnects mid-stream.
In PeopleController, the action wires the enumerator to the response. ActionController::Live is deliberately avoided in favor of setting self.response_body to an enumerable, which is simpler and plays well with most servers. Content-Disposition marks it as an attachment with a timestamped filename, and Last-Modified/Cache-Control headers discourage buffering by intermediaries. The Content-Type is text/csv.
Resilience is the interesting part. Streaming a response means the HTTP status (200) is already committed before the first row is fetched, so an exception halfway through cannot be turned into a 500. PeopleCsvStream#each therefore rescues StandardError, logs it, and writes a sentinel comment line (# export truncated) into the stream so downstream consumers can detect a partial file rather than silently trusting it. The ensure block guarantees the database connection is returned to the pool via clear_active_connections!, which matters because streaming holds a connection for the full duration of the response.
The trade-offs are worth noting: streamed responses cannot report progress, retries must re-run the whole query, and very long exports can still exhaust a connection pool under concurrency. For truly massive or scheduled exports, generating the file in a background job and handing back a signed URL is the better pattern; this in-request streaming approach is ideal for interactive, moderately large downloads where waiting is acceptable but memory is not.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.