Skip to main content

Overview

Phlex::CSV is a specialized class for generating CSV (Comma-Separated Values) files from Ruby collections. Unlike HTML and SVG views, CSV views are initialized with a collection and use row_template instead of view_template.

Basic CSV Component

class UsersCSV < Phlex::CSV
  def initialize(users)
    super(users)
  end

  def row_template(user)
    column "Name", user.name
    column "Email", user.email
    column "Created", user.created_at.to_date
  end

  private

  def escape_csv_injection? = true
end

users = [
  User.new(name: "Alice", email: "[email protected]", created_at: Time.now),
  User.new(name: "Bob", email: "[email protected]", created_at: Time.now)
]

UsersCSV.new(users).call
You must define the escape_csv_injection? method in every CSV component. This is a security requirement to prevent CSV injection attacks.

The row_template Method

Unlike HTML and SVG which use view_template, CSV components use row_template:
def row_template(record)
  column "Header 1", record.field1
  column "Header 2", record.field2
  column "Header 3", record.field3
end
The row_template method is called once for each item in the collection. The first call establishes the headers and column order.

Defining Columns

Use the column method to add columns:
class ProductsCSV < Phlex::CSV
  def row_template(product)
    column "SKU", product.sku
    column "Name", product.name
    column "Price", product.price
    column "In Stock", product.in_stock? ? "Yes" : "No"
  end

  private

  def escape_csv_injection? = true
end

Column Order

Columns must be defined in the same order for every row:
def row_template(order)
  column "Order ID", order.id
  column "Customer", order.customer.name
  column "Total", order.total
  # Always use the same order!
end
If you define columns in a different order between rows, Phlex will raise a Phlex::RuntimeError with a header mismatch error.

CSV Injection Protection

CSV injection is a security vulnerability where malicious formulas in CSV cells can execute code when opened in spreadsheet programs.

Enabling Protection

Set escape_csv_injection? to true:
class SecureCSV < Phlex::CSV
  def row_template(record)
    column "Name", record.name
    column "Formula", record.formula
  end

  private

  def escape_csv_injection? = true
end
With protection enabled, dangerous values are prefixed with a single quote:
# Input: "=1+1"
# Output: "'=1+1"

Disabling Protection

For secure system-to-system data exchange:
private

def escape_csv_injection? = false
Only disable CSV injection protection if:
  • The CSV will never be opened in a spreadsheet program
  • You’re doing byte-for-byte data exchange between secure systems
  • You understand the security implications

Formula Prefixes

The following characters at the start of a value are considered dangerous:
  • = (equals)
  • + (plus)
  • - (minus)
  • @ (at sign)
  • \t (tab)
  • \r (carriage return)

Headers

Rendering Headers

By default, headers are rendered as the first row:
UsersCSV.new(users).call
# Name,Email,Created
# Alice,[email protected],2026-03-03
# Bob,[email protected],2026-03-03

Disabling Headers

Override render_headers? to disable header rendering:
class NoHeadersCSV < Phlex::CSV
  def row_template(record)
    column "Name", record.name
    column "Value", record.value
  end

  private

  def render_headers? = false
  def escape_csv_injection? = true
end

# Output:
# Alice,100
# Bob,200

Custom Delimiters

Change the delimiter from comma to another character:
class TabDelimitedCSV < Phlex::CSV
  def row_template(record)
    column "Name", record.name
    column "Value", record.value
  end

  def delimiter
    "\t"
  end

  private

  def escape_csv_injection? = true
end

# Call with custom delimiter
TabDelimitedCSV.new(records).call(delimiter: "\t")
The delimiter must be a single character. Multi-character delimiters will raise an error.

Whitespace Handling

Trimming Whitespace

Enable whitespace trimming:
class TrimmedCSV < Phlex::CSV
  def row_template(record)
    column "Name", "  #{record.name}  " # Spaces will be trimmed
  end

  private

  def trim_whitespace? = true
  def escape_csv_injection? = true
end

Custom Collections

Override each_item to customize iteration:
class PagedCSV < Phlex::CSV
  def initialize(collection)
    super(collection)
  end

  def row_template(record)
    column "ID", record.id
    column "Name", record.name
  end

  private

  def each_item(&block)
    # Fetch in batches
    collection.find_each(batch_size: 1000, &block)
  end

  def escape_csv_injection? = true
end

Lifecycle Hooks

Use around_row to wrap row rendering:
class MonitoredCSV < Phlex::CSV
  def row_template(record)
    column "Name", record.name
  end

  def around_row(record)
    start = Time.now
    super # Calls row_template and appends the row
    duration = Time.now - start
    puts "Row rendered in #{duration}s"
  end

  private

  def escape_csv_injection? = true
end
The around_row method wraps the row rendering. You must call super to actually render the row.

Content Type

CSV components have the appropriate content type:
UsersCSV.new(users).content_type
# => "text/csv"

Filename

Provide a filename for CSV downloads:
class ReportCSV < Phlex::CSV
  def filename
    "report-#{Date.today}.csv"
  end

  def row_template(record)
    column "Date", record.date
    column "Amount", record.amount
  end

  private

  def escape_csv_injection? = true
end

# In a Rails controller
def export
  csv = ReportCSV.new(@records)
  send_data csv.call,
            filename: csv.filename,
            type: csv.content_type
end

Complete Example

class Order
  attr_reader :id, :customer_name, :total, :status, :created_at

  def initialize(id:, customer_name:, total:, status:, created_at:)
    @id = id
    @customer_name = customer_name
    @total = total
    @status = status
    @created_at = created_at
  end
end
Use CSV components for data exports, reports, and bulk data operations. They handle proper escaping and formatting automatically.

Build docs developers (and LLMs) love