Rails Runner: Unleashing the Hidden Power of Your Projects

You’ve mastered the basics of Rails, and you’re starting to work on your first professional projects. But you’re facing repetitive tasks: cleaning the database, sending mass emails, updating statistics... And you wonder if there’s an elegant way to run Rails code without going through the web server or the interactive console.


Good news: Rails Runner is exactly the tool you need. Often little-known or underutilized, it remains a formidable weapon in every Rails developer's toolkit. In this comprehensive guide, we’ll explore together how this tool can transform your workflow and save you valuable time.

Introduction to Rails Runner

Before diving into the technical aspects, let’s take a moment to understand what Rails Runner really is and why it deserves your attention. Contrary to what its name might suggest, it isn’t an external gem or a complex plugin, but a native Rails feature, available since the early versions of the framework.

Rails Runner Overview

Rails Runner, accessible via the command rails runner (or rails r in short form), is a Ruby code executor in the full context of your Rails application. Concretely, that means it loads your entire Rails environment — your models, configurations, gems — and lets you run any Ruby code with access to all of these elements.


Imagine it as a bridge between the command line and your application. Where rails console opens an interactive session (REPL), Rails Runner executes a script and exits immediately. It’s this characteristic that makes it particularly well suited for automated tasks and maintenance scripts.

Historically, Rails Runner appeared as early as Rails 1.0 under the name script/runner. With the framework's evolution toward a more modern architecture and the introduction of the unified rails command, it became rails runner from Rails 3 onwards. But its essence remained the same: to provide a simple and direct way to run Rails code outside the HTTP request-response cycle.

Key Features of Rails Runner

What makes Rails Runner truly unique is its flexibility of use. You can use it in three different ways:

  1. Inline code execution: Directly on the command line
  2. File execution: By pointing to a Ruby script
  3. Reading from standard input: For advanced Unix pipelines


This versatility comes with several technical advantages:

  1. Full access to the Rails environment: All your models, helpers, and configurations are available
  2. Environment management: Ability to run code in development, production, test, or any other custom environment
  3. Integration with system tools: Compatible with cron, systemd, and other schedulers
  4. No HTTP overhead: Unlike a web request, no server or routing overhead


Let's take a concrete example. You want to check how many users signed up today:

rails runner "puts User.where('created_at >= ?', Date.today).count"


Simple, direct, and effective. No need to open a console, type the code, and then quit. Perfect for quick checks or automated scripts.

Principaux Avantages du Rails Runner

Now that you understand what Rails Runner is, let's explore why you should integrate it into your daily workflow.


Ease of use: Unlike writing full Rake tasks, Rails Runner lets you quickly run code without creating a task file. For ad-hoc operations or simple scripts, it’s a substantial time saver.


Easier automation: Rails Runner integrates naturally with system automation tools. Whether you use cron on Linux, launchd on macOS, or modern solutions like systemd timers, Rails Runner becomes the perfect bridge between your operating system and your Rails application.


Optimized performance: By avoiding the HTTP layer, you save resources. No web server to start, no middleware to traverse, no session to manage. Just your code and your database. For batch processing or data migrations, this saving can make a meaningful difference in execution time.


Debugging and maintenance: Rails Runner also simplifies maintenance tasks. Need to fix production data? Rather than going through the interactive console (and risking a misstep), you can write a locally tested script, then run it in production via Runner with confidence that it will do exactly what you expect.

Basic Operation and Configuration

Understanding how Rails Runner works behind the scenes will help you use it more effectively and avoid common pitfalls. Let’s dive into the technical mechanisms and configuration steps.

Installation and Configuration

One of the big advantages of Rails Runner is that it requires no special installation. If you have a functioning Rails application, you already have Rails Runner. It’s a tool included in Rails core.


To verify that everything works, try this simple command:

rails runner "puts Rails.env"


You should see the current environment printed (probably development). If that works, you’re ready to use Rails Runner.


However, for optimal usage, a few configurations can be useful:


Environment management: By default, Rails Runner runs in the environment specified by RAILS_ENV (or development if not defined). To run code in production:

RAILS_ENV=production rails runner "MonScript.execute"

Or more explicitly:

rails runner -e production "MonScript.execute"


Organizing scripts: Create a dedicated directory for your Runner scripts. By convention, many developers use lib/scripts/ or app/scripts/:

mkdir -p lib/scripts

Then create your scripts in this directory:

# lib/scripts/cleanup_old_users.rb
class CleanupOldUsers
def self.execute
deleted_count = User.where('last_sign_in_at < ?', 2.years.ago)
.where(active: false)
.destroy_all
.count
puts "Suppression de #{deleted_count} utilisateurs inactifs"
end
end
CleanupOldUsers.execute if __FILE__ == $0

Note the last line: if __FILE__ == $0. This condition ensures that the script runs only when called directly, not when required by another file. It’s a good practice that makes your scripts more modular.


To run this script:

rails runner lib/scripts/cleanup_old_users.rb

Best Practices for Configuration


Now that you know how to configure Rails Runner, let’s see how to use it professionally and avoid common mistakes.


1. Always specify the environment explicitly

In production or automation contexts, never rely on defaults. Always specify the environment:

rails runner -e production "Script.run"

This avoids surprises, such as accidentally running a cleanup script on your development database instead of production (or vice versa, which would be even worse).


2. Handle errors properly

Rails Runner returns the exit code of the script it runs. Use this to your advantage in your automated scripts:

# lib/scripts/safe_cleanup.rb
begin
result = DangerousOperation.perform
puts "Success: #{result}"
exit 0
rescue StandardError => e
puts "Error: #{e.message}"
puts e.backtrace.join("\n")
exit 1
end


In your cron jobs or shell scripts, you can then check for success:

#!/bin/bash
rails runner lib/scripts/safe_cleanup.rb
if [ $? -eq 0 ]; then
echo "Script executed successfully"
else
echo "Script failure" >&2
# Send an alert, log the error, etc.
fi


3. Use Rails logging

Rather than sprinkling puts throughout your code, use Rails' logging system. It gives you more control and better traceability:

# lib/scripts/logged_operation.rb
class LoggedOperation
def self.execute
Rails.logger.info "Starting operation"
User.find_each do |user|
Rails.logger.debug "Processing user #{user.id}"
# Processing...
end
Rails.logger.info "Operation finished"
end
end


4. Beware of memory leaks

A common pitfall with Rails Runner: memory leaks when processing large data volumes. Use find_each or find_in_batches rather than all:

# ❌ Bad: loads all users into memory
User.all.each do |user|
user.process_something
end
# ✅ Good: process in batches of 1000
User.find_each(batch_size: 1000) do |user|
user.process_something
end


5. Test your scripts

Your Runner scripts are code like any other. Test them! Create specs for your scripts in spec/scripts/:

# spec/scripts/cleanup_old_users_spec.rb
require 'rails_helper'
require './lib/scripts/cleanup_old_users'
RSpec.describe CleanupOldUsers do
describe '.execute' do
it 'deletes inactive users for more than 2 years' do
old_user = create(:user, last_sign_in_at: 3.years.ago, active: false)
recent_user = create(:user, last_sign_in_at: 1.year.ago, active: false)
expect { CleanupOldUsers.execute }
.to change { User.count }.by(-1)
expect(User.exists?(old_user.id)).to be false
expect(User.exists?(recent_user.id)).to be true
end
end
end

Practical Applications of Rails Runner

Theory is fine, but practice is better! Let’s now explore concrete use cases that will transform how you work with Rails.

Running Periodic Tasks

One of the most common uses of Rails Runner is running periodic tasks. Whether it’s generating reports, cleaning obsolete data, or sending recurring emails, Rails Runner combined with a system scheduler becomes a formidable tool.

Strategies for Scheduled Tasks

To schedule tasks with Rails Runner, you have several options. The traditional one remains cron, but modern alternatives exist.


With Cron (the classic method)

Cron is the standard task scheduler on Unix systems. To use it with Rails Runner, edit your crontab:

crontab -e


Then add your tasks:

# Daily at 2:00 AM: cleanup of expired sessions
0 2 * * * cd /path/to/my_app && RAILS_ENV=production bundle exec rails runner "ActiveRecord::SessionStore::Session.where('updated_at > log/cron.log 2>&1
# Every hour: send pending emails
0 * * * * cd /path/to/my_app && RAILS_ENV=production bundle exec rails runner lib/scripts/send_pending_emails.rb >> log/cron.log 2>&1
# Every Monday at 9:00 AM: generate weekly report
0 9 * * 1 cd /path/to/my_app && RAILS_ENV=production bundle exec rails runner "WeeklyReport.generate_and_send" >> log/cron.log 2>&1


Some points to consider:

  1. Always use the full path to your application
  2. Specify bundle exec to ensure using the correct gem versions
  3. Redirect output to a log file (> > log/cron.log 2>&1)
  4. Don’t forget to set RAILS_ENV=production


With Whenever (the Rails-friendly method)

If you prefer a more "Rails" approach, the gem Whenever lets you define your cron tasks in Ruby:

# Gemfile
gem 'Whenever', require: false


Then create a config/schedule.rb file:

# config/schedule.rb
set :output, 'log/cron.log'
set :environment, 'production'
every 1.day, at: '2:00 am' do
runner "ActiveRecord::SessionStore::Session.where('updated_at < ?', 30.days.ago).delete_all"
end
every 1.hour do
runner "lib/scripts/send_pending_emails.rb"
end
every :monday, at: '9:00 am' do
runner "WeeklyReport.generate_and_send"
end
# Traditional cron syntax also supported
every '0 */6 * * *' do
runner "DataSyncJob.perform"
end


To generate your crontab:

whenever --update-crontab
The advantage? Your configuration is versioned with your code, more readable, and easier to maintain.


With Modern Solutions: Sidekiq Scheduler or Good Job

If you’re already using a background job system like Sidekiq, why not use its built-in scheduler?


# Gemfile
gem 'sidekiq-scheduler'
# config/sidekiq.yml
:schedule:
cleanup_sessions:
cron: '0 2 * * *'
class: CleanupSessionsJob
send_pending_emails:
every: '1h'
class: SendPendingEmailsJob
weekly_report:
cron: '0 9 * * 1'
class: WeeklyReportJob


This approach has several advantages:

  1. No dependency on cron: everything is managed by Sidekiq
  2. Integrated monitoring: you can see your scheduled jobs in the Sidekiq interface
  3. Automatic retry: on failure, the job can be retried
  4. Centralized logs: everything is tracked in the same place

Case Studies: Successful Implementations

Now let’s look at real, complete examples that illustrate the power of Rails Runner for periodic tasks.


Case #1: Generating and sending weekly reports

Imagine you need to send every Monday morning a report of last week's signups to your team.

# lib/scripts/weekly_signup_report.rb
class WeeklySignupReport
def self.execute
start_date = 1.week.ago.beginning_of_week
end_date = 1.week.ago.end_of_week
new_users = User.where(created_at: start_date..end_date)
report_data = {
total: new_users.count,
by_day: new_users.group_by_day(:created_at).count,
by_source: new_users.group(:source).count,
revenue: Order.where(user: new_users).sum(:amount)
}
Rails.logger.info "Génération du rapport hebdomadaire"
Rails.logger.info "Période : #{start_date.to_date} - #{end_date.to_date}"
Rails.logger.info "Nouvelles inscriptions : #{report_data[:total]}"
# Send by email
AdminMailer.weekly_report(report_data, start_date, end_date).deliver_now
# Save to DB for history
WeeklyReport.create!(
start_date: start_date,
end_date: end_date,
data: report_data
)
Rails.logger.info "Rapport envoyé avec succès"
rescue StandardError => e
Rails.logger.error "Erreur lors de la génération du rapport : #{e.message}"
# Alert the team in case of error
AdminMailer.error_notification(e).deliver_now
raise
end
end
WeeklySignupReport.execute if __FILE__ == $0


Configuration cron :

0 9 * * 1 cd /var/www/myapp && RAILS_ENV=production bundle exec rails runner lib/scripts/weekly_signup_report.rb


Case #2: Automatic cleanup of temporary data


Rails applications often accumulate temporary data: expired sessions, temporary uploaded files, obsolete caches...

# lib/scripts/cleanup_temporary_data.rb
class CleanupTemporaryData
def self.execute
Rails.logger.info "Starting cleanup of temporary data"
results = {
expired_sessions: cleanup_sessions,
old_temp_files: cleanup_temp_files,
unconfirmed_users: cleanup_unconfirmed_users,
expired_tokens: cleanup_expired_tokens
}
total = results.values.sum
Rails.logger.info "Cleanup finished: #{total} items deleted"
Rails.logger.info "Details: #{results}"
# Notification if a lot was deleted (possible anomaly)
if total > 10000
AdminMailer.cleanup_alert(results).deliver_now
end
results
end
private
def self.cleanup_sessions
if defined?(ActiveRecord::SessionStore::Session)
deleted = ActiveRecord::SessionStore::Session
.where('updated_at < ?', 30.days.ago)
.delete_all
Rails.logger.info "Expired sessions deleted: #{deleted}"
deleted
else
0
end
end
def self.cleanup_temp_files
temp_path = Rails.root.join('tmp', 'uploads')
return 0 unless File.directory?(temp_path)
deleted = 0
Dir.glob(temp_path.join('*')).each do |file|
if File.mtime(file) < 24.hours.ago
File.delete(file)
deleted += 1
end
end
Rails.logger.info "Temporary files deleted: #{deleted}"
deleted
end
def self.cleanup_unconfirmed_users
if User.column_names.include?('confirmed_at')
deleted = User.where(confirmed_at: nil)
.where('created_at < ?', 7.days.ago)
.delete_all
Rails.logger.info "Unconfirmed users deleted: #{deleted}"
deleted
else
0
end
end
def self.cleanup_expired_tokens
deleted = 0
if defined?(AccessToken)
deleted += AccessToken.where('expires_at < ?', Time.current).delete_all
end
if defined?(ResetPasswordToken)
deleted += ResetPasswordToken.where('created_at < ?', 24.hours.ago).delete_all
end
Rails.logger.info "Expired tokens deleted: #{deleted}"
deleted
end
end
CleanupTemporaryData.execute if __FILE__ == $0

This modular approach makes it easy to enable/disable certain cleanups as needed.


Case #3: Synchronization with an external service

Many applications need to synchronize their data with third-party services (CRM, analytics, etc.).

# lib/scripts/sync_to_analytics.rb
class SyncToAnalytics
BATCH_SIZE = 100
def self.execute
Rails.logger.info "Starting synchronization to Analytics"
last_sync = SyncLog.where(service: 'analytics').order(created_at: :desc).first
since = last_sync&.created_at || 24.hours.ago
events_to_sync = AnalyticsEvent.where('created_at > ?', since)
.where(synced: false)
total = events_to_sync.count
synced = 0
failed = 0
Rails.logger.info "#{total} events to synchronize"
events_to_sync.find_in_batches(batch_size: BATCH_SIZE) do |batch|
begin
# Batch send to external service
response = AnalyticsService.send_batch(batch.map(&:to_analytics_format))
if response.success?
batch.each { |event| event.update!(synced: true) }
synced += batch.size
Rails.logger.debug "Batch of #{batch.size} events synchronized"
else
failed += batch.size
Rails.logger.error "Batch synchronization failed: #{response.error}"
end
rescue StandardError => e
failed += batch.size
Rails.logger.error "Error during synchronization: #{e.message}"
end
sleep 0.5 # Rate limiting
end
# Record the result
SyncLog.create!(
service: 'analytics',
total_events: total,
synced_events: synced,
failed_events: failed
)
Rails.logger.info "Synchronization finished: #{synced}/#{total} successful"
# Alert if too many failures
if failed > total * 0.1
AdminMailer.sync_failure_alert('analytics', failed, total).deliver_now
end
end
end
SyncToAnalytics.execute if __FILE__ == $0

Advanced Database Manipulation

Rails Runner shines for database operations that fall outside the usual web request cycle. Data migrations, mass corrections, optimizations... Let’s see how to leverage this power.

Query Optimization

One of the major advantages of Rails Runner for database operations is the ability to finely optimize queries without the constraints of the HTTP request-response cycle.


Batch processing for large volumes

When you need to process millions of records, memory becomes your main enemy. Rails Runner lets you implement efficient batch processing strategies:

# lib/scripts/migrate_user_emails.rb
class MigrateUserEmails
BATCH_SIZE = 1000
def self.execute
total = User.where(email_normalized: nil).count
processed = 0
Rails.logger.info "Migrating #{total} emails"
User.where(email_normalized: nil).find_in_batches(batch_size: BATCH_SIZE) do |batch|
# Use a transaction for each batch
ActiveRecord::Base.transaction do
batch.each do |user|
user.update_column(:email_normalized, user.email.downcase.strip)
end
end
processed += batch.size
progress = (processed.to_f / total * 100).round(2)
Rails.logger.info "Progress: #{processed}/#{total} (#{progress}%)"
end
Rails.logger.info "Migration finished"
end
end


Raw SQL queries for performance

Sometimes ActiveRecord isn’t the best tool. Rails Runner gives you direct access to the SQL connection:


# lib/scripts/bulk_update_statistics.rb
class BulkUpdateStatistics
def self.execute
Rails.logger.info "Updating user statistics"
# Optimized SQL to avoid N+1s
sql = <<-SQL
UPDATE users
SET
posts_count = (
SELECT COUNT(*)
FROM posts
WHERE posts.user_id = users.id
),
comments_count = (
SELECT COUNT(*)
FROM comments
WHERE comments.user_id = users.id
),
last_activity_at = (
SELECT MAX(created_at)
FROM (
SELECT created_at FROM posts WHERE posts.user_id = users.id
UNION ALL
SELECT created_at FROM comments WHERE comments.user_id = users.id
) AS activities
)
WHERE updated_at < NOW() - INTERVAL '1 day'
SQL
start_time = Time.current
result = ActiveRecord::Base.connection.execute(sql)
duration = Time.current - start_time
Rails.logger.info "#{result.cmd_tuples} users updated in #{duration.round(2)}s"
end
end


Using indexes and EXPLAIN

Rails Runner is great for testing and optimizing your queries. Here’s a script to help you identify slow queries:

# lib/scripts/analyze_slow_queries.rb
class AnalyzeSlowQueries
def self.execute
queries_to_analyze = [
{ name: 'Recent posts', query: -> { Post.includes(:user).where('created_at > ?', 7.days.ago) } },
{ name: 'Active users', query: -> { User.where('last_sign_in_at > ?', 30.days.ago).order(:email) } },
{ name: 'Popular comments', query: -> { Comment.joins(:post).where('comments.created_at > ?', 1.day.ago).group('posts.id') } }
]
Rails.logger.info "Analysis of slow queries"
queries_to_analyze.each do |query_info|
Rails.logger.info "\n--- #{query_info[:name]} ---"
# Get the SQL query
relation = query_info[:query].call
sql = relation.to_sql
Rails.logger.info "SQL: #{sql}"
# EXPLAIN of the query
explain = ActiveRecord::Base.connection.execute("EXPLAIN ANALYZE #{sql}")
Rails.logger.info "EXPLAIN :"
explain.each { |row| Rails.logger.info " #{row.values.join(' ')}" }
# Measure execution time
start_time = Time.current
count = relation.count
duration = ((Time.current - start_time) * 1000).round(2)
Rails.logger.info "Results: #{count} records in #{duration}ms"
if duration > 100
Rails.logger.warn "⚠️ Slow query detected!"
end
end
end
end

Complex Transaction Management

Rails Runner is particularly well suited to manage complex transactions that involve multiple models or conditional operations.


Data migrations with automatic rollback

When you need to migrate data between different models, transactional management becomes crucial:

# lib/scripts/migrate_orders_to_new_schema.rb
class MigrateOrdersToNewSchema
def self.execute
Rails.logger.info "Starting migration of orders"
total = LegacyOrder.count
migrated = 0
failed = 0
LegacyOrder.find_each do |legacy_order|
ActiveRecord::Base.transaction do
# Create the new order
new_order = Order.create!(
user_id: legacy_order.customer_id,
status: map_status(legacy_order.state),
total: legacy_order.amount,
created_at: legacy_order.order_date
)
# Migrate items
legacy_order.items.each do |legacy_item|
OrderItem.create!(
order: new_order,
product_id: legacy_item.product_ref,
quantity: legacy_item.qty,
price: legacy_item.unit_price
)
end
# Migrate payments
if legacy_order.payment
Payment.create!(
order: new_order,
amount: legacy_order.payment.amount,
method: legacy_order.payment.type,
processed_at: legacy_order.payment.date
)
end
# Mark as migrated
legacy_order.update!(migrated: true)
migrated += 1
if migrated % 100 == 0
Rails.logger.info "Progress: #{migrated}/#{total}"
end
end
rescue StandardError => e
failed += 1
Rails.logger.error "Migration failed for order #{legacy_order.id}: #{e.message}"
# The transaction is automatically rolled back
end
Rails.logger.info "Migration finished: #{migrated} successes, #{failed} failures"
end
private
def self.map_status(legacy_status)
case legacy_status
when 'new' then 'pending'
when 'paid' then 'processing'
when 'shipped' then 'shipped'
when 'completed' then 'delivered'
when 'cancelled' then 'cancelled'
else 'pending'
end
end
end


Complex conditional operations

Sometimes you need to perform operations that depend on multiple conditions and cascading checks:

# lib/scripts/process_subscription_renewals.rb
class ProcessSubscriptionRenewals
def self.execute
Rails.logger.info "Processing subscription renewals"
expiring_subscriptions = Subscription
.where('expires_at <= ?', 3.days.from_now)
.where('expires_at > ?', Time.current)
.where(auto_renew: true)
Rails.logger.info "#{expiring_subscriptions.count} subscriptions to renew"
success = 0
failed = 0
expiring_subscriptions.find_each do |subscription|
result = renew_subscription(subscription)
if result[:success]
success += 1
Rails.logger.info "✓ Subscription #{subscription.id} renewed"
else
failed += 1
Rails.logger.warn "✗ Renewal for subscription #{subscription.id} failed: #{result[:reason]}"
end
end
Rails.logger.info "Processing finished: #{success} successful, #{failed} failures"
# Notify the team
SubscriptionMailer.renewal_report(success, failed).deliver_later
end
private
def self.renew_subscription(subscription)
ActiveRecord::Base.transaction do
user = subscription.user
# Check payment method
unless user.payment_method&.valid?
raise "Invalid payment method"
end
# Calculate amount
plan = subscription.plan
amount = plan.price
# Apply a promotion if available
if promotion = find_applicable_promotion(user, plan)
amount = amount * (1 - promotion.discount_percentage / 100.0)
Rails.logger.info " Promotion applied: #{promotion.code}"
end
# Process the payment
payment = Payment.create!(
user: user,
amount: amount,
payment_method: user.payment_method,
description: "Renewal #{plan.name}"
)
result = PaymentProcessor.charge(payment)
unless result.success?
raise "Payment failed: #{result.error_message}"
end
# Update the subscription
subscription.update!(
expires_at: calculate_new_expiration(subscription),
last_renewed_at: Time.current
)
# Create an invoicing record
Invoice.create!(
user: user,
subscription: subscription,
payment: payment,
amount: amount
)
# Send confirmation email
SubscriptionMailer.renewal_confirmation(subscription, payment).deliver_later
{ success: true }
end
rescue StandardError => e
# In case of error, everything is rolled back automatically
Rails.logger.error " Error: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
# Notify the user
SubscriptionMailer.renewal_failed(subscription, e.message).deliver_later
{ success: false, reason: e.message }
end
def self.find_applicable_promotion(user, plan)
Promotion.active
.where('valid_from = ?', Time.current, Time.current)
.where('plan_id = ? OR plan_id IS NULL', plan.id)
.order(discount_percentage: :desc)
.first
end
def self.calculate_new_expiration(subscription)
case subscription.plan.billing_period
when 'monthly' then subscription.expires_at + 1.month
when 'quarterly' then subscription.expires_at + 3.months
when 'yearly' then subscription.expires_at + 1.year
else subscription.expires_at + 1.month
end
end
end


This script shows how Rails Runner can manage complex business processes with robust error handling and nested transactions.

Advanced Use Cases

Now that you’ve mastered the basics and practical applications, let’s explore more advanced use cases that will really set your skills apart.

Automation with Rails Runner

Automation goes beyond simple periodic execution. It’s about creating complete, intelligent workflows that adapt to data and situations.

Data Transformation Scripts

Data transformation is a recurring task in an application's life. Whether normalizing legacy data, enriching information, or preparing exports, Rails Runner is the ideal tool.


ETL (Extract, Transform, Load) simplified

Here’s a complete example of an ETL script that extracts data from an external API, transforms it, and loads it into your database:


# lib/scripts/import_product_catalog.rb
require 'httparty'
class ImportProductCatalog
API_ENDPOINT = ENV['SUPPLIER_API_URL']
API_KEY = ENV['SUPPLIER_API_KEY']
def self.execute
Rails.logger.info "Starting product catalog import"
stats = {
fetched: 0,
created: 0,
updated: 0,
skipped: 0,
errors: 0
}
# EXTRACT : Retrieve data from API
products_data = fetch_products_from_api
stats[:fetched] = products_data.size
Rails.logger.info "#{stats[:fetched]} products retrieved from API"
# TRANSFORM & LOAD
products_data.each do |raw_product|
begin
result = process_product(raw_product)
stats[result] += 1
rescue StandardError => e
stats[:errors] += 1
Rails.logger.error "Error processing product #{raw_product['id']}: #{e.message}"
end
end
# Final report
Rails.logger.info "Import finished"
Rails.logger.info "Creations: #{stats[:created]}"
Rails.logger.info "Updates: #{stats[:updated]}"
Rails.logger.info "Skipped: #{stats[:skipped]}"
Rails.logger.info "Errors: #{stats[:errors]}"
# Notification
ImportMailer.catalog_import_report(stats).deliver_now
stats
end
private
def self.fetch_products_from_api
all_products = []
page = 1
loop do
Rails.logger.info "Fetching page #{page}..."
response = HTTParty.get(
"#{API_ENDPOINT}/products",
headers: { 'Authorization' => "Bearer #{API_KEY}" },
query: { page: page, per_page: 100 }
)
break unless response.success?
products = response.parsed_response['products']
break if products.empty?
all_products.concat(products)
page += 1
sleep 0.5 # Rate limiting
end
all_products
end
def self.process_product(raw_product)
# TRANSFORM : Normalization of data
transformed_data = transform_product_data(raw_product)
# Check if product already exists
existing_product = Product.find_by(external_id: transformed_data[:external_id])
if existing_product
# Update only if data has changed
if product_changed?(existing_product, transformed_data)
existing_product.update!(transformed_data)
:updated
else
:skipped
end
else
# LOAD : Create new product
Product.create!(transformed_data)
:created
end
end
def self.transform_product_data(raw_product)
{
external_id: raw_product['id'],
name: clean_text(raw_product['name']),
description: clean_text(raw_product['description']),
price: parse_price(raw_product['price']),
category: map_category(raw_product['category_code']),
sku: raw_product['sku']&.upcase,
stock_quantity: raw_product['stock'].to_i,
active: raw_product['status'] == 'active',
metadata: {
supplier_updated_at: raw_product['updated_at'],
supplier_category: raw_product['category_code']
}
}
end
def self.clean_text(text)
return nil if text.blank?
text.strip.gsub(/\s+/, ' ')
end
def self.parse_price(price_string)
price_string.to_s.gsub(/[^0-9.]/, '').to_f
end
def self.map_category(category_code)
mapping = {
'ELEC' => 'Électronique',
'HOME' => 'Maison',
'SPRT' => 'Sport',
'BOOK' => 'Livres'
}
mapping[category_code] || 'Autre'
end
def self.product_changed?(product, new_data)
new_data.any? { |key, value| product.send(key) != value }
end
end
ImportProductCatalog.execute if __FILE__ == $0


Data enrichment with external services

Sometimes you need to enrich your data with information from third-party services (geolocation, validation, etc.).

# lib/scripts/enrich_user_locations.rb
class EnrichUserLocations
GEOCODING_SERVICE = 'https://api.geocoding.com/v1'
BATCH_SIZE = 50
def self.execute
Rails.logger.info "Enriching user locations"
users_to_enrich = User.where(latitude: nil)
.where.not(city: nil)
.where.not(country: nil)
total = users_to_enrich.count
processed = 0
enriched = 0
Rails.logger.info "#{total} users to enrich"
users_to_enrich.find_in_batches(batch_size: BATCH_SIZE) do |batch|
# Prepare grouped requests
locations = batch.map do |user|
"#{user.city}, #{user.country}"
end
# Grouped call to geocoding service
geocoded_results = geocode_batch(locations)
# Update users
batch.each_with_index do |user, index|
if result = geocoded_results[index]
user.update!(
latitude: result['lat'],
longitude: result['lng'],
timezone: result['timezone'],
region: result['region']
)
enriched += 1
end
end
processed += batch.size
Rails.logger.info "Progression: #{processed}/#{total} (#{enriched} enriched)"
sleep 1 # Rate limiting
end
Rails.logger.info "Enrichment finished: #{enriched}/#{total} users"
end
private
def self.geocode_batch(locations)
response = HTTParty.post(
"#{GEOCODING_SERVICE}/batch",
body: { locations: locations }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
response.success? ? response.parsed_response : []
rescue StandardError => e
Rails.logger.error "Geocoding error: #{e.message}"
[]
end
end

Automating Integrations

Rails Runner is perfect for automating integrations with other systems, whether CRM, analytics tools, or monitoring services.


Bidirectional CRM synchronization

# lib/scripts/sync_crm.rb
class SyncCRM
CRM_API = ENV['CRM_API_URL']
def self.execute
Rails.logger.info "Bidirectional CRM synchronization"
# Phase 1: Export to CRM
export_to_crm
# Phase 2: Import from CRM
import_from_crm
Rails.logger.info "CRM synchronization completed"
end
private
def self.export_to_crm
Rails.logger.info "Export to CRM..."
# Modified users since last sync
last_sync = SyncLog.where(direction: 'export', service: 'crm')
.order(created_at: :desc)
.first
since = last_sync&.created_at || 24.hours.ago
modified_users = User.where('updated_at > ?', since)
Rails.logger.info "#{modified_users.count} users to export"
exported = 0
modified_users.find_each do |user|
begin
crm_data = user.to_crm_format
if user.crm_id.present?
# Update
CRMService.update_contact(user.crm_id, crm_data)
else
# Create
crm_id = CRMService.create_contact(crm_data)
user.update_column(:crm_id, crm_id)
end
exported += 1
rescue StandardError => e
Rails.logger.error "Export error for user #{user.id}: #{e.message}"
end
end
SyncLog.create!(
direction: 'export',
service: 'crm',
records_count: exported
)
Rails.logger.info "#{exported} users exported"
end
def self.import_from_crm
Rails.logger.info "Import from CRM..."
# Get modified contacts
modified_contacts = CRMService.get_modified_contacts(since: 1.hour.ago)
Rails.logger.info "#{modified_contacts.count} contacts to import"
imported = 0
modified_contacts.each do |crm_contact|
begin
user = User.find_by(crm_id: crm_contact['id'])
if user
#Selective update (don’t overwrite local data)
update_user_from_crm(user, crm_contact)
imported += 1
end
rescue StandardError => e
Rails.logger.error "Import error for contact #{crm_contact['id']}: #{e.message}"
end
end
SyncLog.create!(
direction: 'import',
service: 'crm',
records_count: imported
)
Rails.logger.info "#{imported} users imported"
end
def self.update_user_from_crm(user, crm_contact)
# Update only CRM-managed fields
updates = {}
if crm_contact['company'] && crm_contact['company'] != user.company
updates[:company] = crm_contact['company']
end
if crm_contact['tags'] && crm_contact['tags'] != user.crm_tags
updates[:crm_tags] = crm_contact['tags']
end
if crm_contact['status'] && crm_contact['status'] != user.crm_status
updates[:crm_status] = crm_contact['status']
end
user.update!(updates) if updates.any?
end
end
SyncCRM.execute if __FILE__ == $0


Monitoring and alerts pipeline

# lib/scripts/health_check.rb
class HealthCheck
def self.execute
Rails.logger.info "Application health check"
checks = [
check_database,
check_redis,
check_storage,
check_external_services,
check_background_jobs,
check_performance_metrics
]
failed_checks = checks.select { |check| !check[:healthy] }
if failed_checks.any?
Rails.logger.error "#{failed_checks.size} checks failed"
AlertService.send_alert(
severity: 'high',
title: 'Health Check Failed',
details: failed_checks
)
else
Rails.logger.info "All checks passed"
end
# Record metrics
HealthCheckLog.create!(
checks: checks,
healthy: failed_checks.empty?,
performed_at: Time.current
)
end
private
def self.check_database
start = Time.current
ActiveRecord::Base.connection.execute('SELECT 1')
response_time = ((Time.current - start) * 1000).round(2)
{
name: 'Database',
healthy: response_time < 100,
response_time: response_time,
details: "Response time: #{response_time}ms"
}
rescue StandardError => e
{
name: 'Database',
healthy: false,
error: e.message
}
end
def self.check_redis
start = Time.current
Redis.current.ping
response_time = ((Time.current - start) * 1000).round(2)
{
name: 'Redis',
healthy: response_time < 50,
response_time: response_time
}
rescue StandardError => e
{
name: 'Redis',
healthy: false,
error: e.message
}
end
def self.check_storage
disk_usage = `df -h / | tail -1 | awk '{print $5}'`.strip.to_i
{
name: 'Storage',
healthy: disk_usage < 80,
disk_usage: disk_usage,
details: "Disk usage: #{disk_usage}%"
}
end
def self.check_external_services
services = [
{ name: 'Payment API', url: ENV['PAYMENT_API_URL'] },
{ name: 'Email Service', url: ENV['EMAIL_SERVICE_URL'] }
]
all_healthy = true
services.each do |service|
begin
response = HTTParty.get("#{service[:url]}/health", timeout: 5)
service[:healthy] = response.success?
rescue StandardError
service[:healthy] = false
all_healthy = false
end
end
{
name: 'External Services',
healthy: all_healthy,
services: services
}
end
def self.check_background_jobs
if defined?(Sidekiq)
stats = Sidekiq::Stats.new
failed_count = stats.failed
retry_count = stats.retry_size
{
name: 'Background Jobs',
healthy: failed_count < 100 && retry_count < 50,
failed: failed_count,
retrying: retry_count
}
else
{ name: 'Background Jobs', healthy: true, details: 'Not configured' }
end
end
def self.check_performance_metrics
avg_response_time = calculate_avg_response_time
error_rate = calculate_error_rate
{
name: 'Performance',
healthy: avg_response_time < 500 && error_rate < 1.0,
avg_response_time: avg_response_time,
error_rate: error_rate
}
end
def self.calculate_avg_response_time
# Implementation dependent on your logging system
200 # Placeholder
end
def self.calculate_error_rate
# Percentage of errors over the last hour
0.5 # Placeholder
end
end

Towards Advanced Practice

You now have a complete view of Rails Runner and its possibilities. But like any powerful tool, its effectiveness depends on your ability to integrate it intelligently into your workflow.

Connecting Theory and Practice

The examples in this guide aren’t mere theoretical exercises. They’re proven patterns used daily in production Rails apps. The key to mastering them? Regular practice and experimentation.


Start small. Identify a repetitive task in your current project — perhaps cleaning up obsolete data or sending a weekly report. Write a Rails Runner script for that task. Test it locally. Deploy it to production. Observe the results.

Then gradually increase complexity. Add error handling. Implement detailed logging. Create notifications in case of problems. Optimize performance to handle larger volumes.


A few principles to keep in mind:

  1. Idempotence: Your scripts should be safe to run multiple times without causing problems. If a script stops midway, it should be able to resume cleanly.
  2. Observability: Log heavily. In production you won’t have access to a debugging console. Your logs are your window into what’s happening.
  3. Resilience: Anticipate failures. API connections time out, databases go down temporarily, malformed data... Your script should handle these cases.
  4. Performance: Remember that Rails Runner loads your entire environment. For very frequent tasks, consider other solutions (background jobs, Redis, etc.).
  5. Security: Automated scripts are often run with elevated privileges. Be especially vigilant about data validation and SQL injection.

Resources and Practical Continuity

Rails Runner is just one tool among many in your Rails developer toolbox. To go further, here are some directions to explore:

Official Documentation

  1. Rails Guides - Command Line
  2. Rails API - Rails::Command::RunnerCommand

Supporting Tools

  1. Whenever: Cron management in Ruby
  2. Sidekiq Scheduler: Modern alternative to cron
  3. Good Job: Background jobs with integrated scheduler
  4. Kamal: For deployment and orchestration


Advanced Patterns to Study

  1. Service Objects: Structure your business logic in a reusable way
  2. Command Pattern: For complex and transactional operations
  3. ETL Pipelines: For large-scale data transformations
  4. Event Sourcing: For audit and traceability


Continuous Practice

The best way to progress? Contribute to open-source projects or create your own gems. You’ll see how other developers structure their scripts, handle errors, and optimize performance.

Also browse Rails’ own source code. The commands rails runner, rails console, and others are written in Ruby. Understanding their implementation will give you valuable insights into best practices.


Community

The Rails community is active and supportive. Don’t hesitate to:

  1. Ask questions on Stack Overflow
  2. Join discussions on Reddit r/rails
  3. Follow influential Rails developers on Twitter/X
  4. Attend Ruby/Rails meetups in your region


Conclusion

Rails Runner is much more than a simple command to execute Ruby code. It’s a productivity multiplier, a powerful automation tool, and an indispensable companion for anything that falls outside the classic HTTP request-response flow.

You now have all the keys you need:

  1. Understand what Rails Runner is and how it works
  2. Use it for periodic tasks and data cleansing
  3. Effectively manipulate your database
  4. Create complex automation and integration scripts
  5. Apply best practices for robust, maintainable code


But remember: knowledge without practice remains theoretical. Open your terminal, create your first script, and dive in. You’ll make mistakes — that’s normal and even desirable. Each mistake is a lesson that brings you closer to mastery.


And when you’ve automated your first recurring task, when you’ve written your first data-migration script that works perfectly, you’ll feel that unique developer satisfaction: having tamed the complexity and made the machine work for you.


Good code, and may Rails Runner unleash the hidden power of your projects! 🚀