Ruby on Rails Interview Questions and Answers
Last updated:
Check out 45 of the most common Ruby on Rails interview questions, then take an AI-powered practice interview
Q1Trace what happens between Puma accepting a request and your controller action running
BasicRequest Lifecycle
Answer
Puma parses the HTTP request into a Rack env hash and calls Rails.application.call(env). Rails.application is itself a Rack app, so the env walks down the middleware stack, which you can print with bin/rails middleware. The order matters and interviewers probe it.
ActionDispatch::HostAuthorization rejects unknown Host headers, Rack::Sendfile and ActionDispatch::Static handle files, ActionDispatch::Executor wraps the request in the reloader and, critically, returns the ActiveRecord connection to the pool when the request finishes. ActionDispatch::RequestId stamps X-Request-Id, Rails::Rack::Logger opens the log block, ShowExceptions and DebugExceptions turn raised errors into 500 or the error page, Cookies plus Session::CookieStore decode the session, and Rack::MethodOverride lets a form send _method=patch. At the bottom sits the router.
Journey matches the path against config/routes.rb, extracts params, and dispatches to the controller class, which is instantiated fresh per request. The controller runs process_action: before_action callbacks in declaration order, the action itself, then implicit or explicit rendering, then after_action. The response tuple of status, headers and body bubbles back up through every middleware in reverse. Two facts win points: connection checkout is lazy (the first query in the request grabs a connection, the executor releases it at the end), and a controller instance is never shared between requests, which is why instance variables are safe but class variables are not.
# See the real stack for your app
# $ bin/rails middleware
# use ActionDispatch::HostAuthorization
# use ActionDispatch::Executor
# use ActionDispatch::RequestId
# use ActionDispatch::Session::CookieStore
# run MyApp::Application.routes
# config/application.rb
class RequestTimer
def initialize(app) = @app = app
def call(env)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status, headers, body = @app.call(env)
headers['X-App-Duration'] = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round.to_s
[status, headers, body]
end
end
config.middleware.insert_after ActionDispatch::RequestId, RequestTimer
Key Points
- Rails.application is a Rack app; bin/rails middleware prints the real stack
- ActionDispatch::Executor handles reloading and returns the DB connection to the pool
- Router (Journey) dispatches to a freshly instantiated controller per request
- process_action runs before_action, action, render, after_action in order
Q2How does Zeitwerk autoloading decide which file defines which constant, and what breaks it?
BasicAutoloading
Answer
Zeitwerk maps file paths to constant names by convention. Every directory in the autoload paths is a namespace root, so app/models/user.rb must define User, app/services/billing/invoice_builder.rb must define Billing::InvoiceBuilder, and app/models/concerns is a root directory by default so app/models/concerns/archivable.rb defines Archivable, not Concerns::Archivable. When the file does not define the expected constant you get the classic error: expected file app/services/api_client.rb to define constant ApiClient.
The most common cause in Indian codebases is acronym casing. api_client.rb camelizes to ApiClient, so a class named APIClient fails until you register an inflection in config/initializers/inflections.rb. In development Rails autoloads lazily and reloads changed files between requests; in production config.eager_load = true loads everything at boot, which is why a naming mistake that never surfaced locally crashes the container on deploy. Run bin/rails zeitwerk:check in CI to catch that before it reaches production.
Since Rails 7.1 you opt lib into autoloading explicitly with config.autoload_lib(ignore: %w[assets tasks generators]), because lib usually holds rake tasks and templates that must not be eager loaded. Two rules save you pain: never call require or require_dependency for your own application code, and never reference an autoloaded constant at class body level in an initializer, because the reloader will hand you a stale class object after the first code change.
# config/initializers/inflections.rb
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym 'API'
inflect.acronym 'GST'
end
# now app/services/api_client.rb must define APIClient
# config/application.rb
config.autoload_lib(ignore: %w[assets tasks generators])
# Catch naming mistakes before deploy
# $ bin/rails zeitwerk:check
# Hold on, I am eager loading the application.
# All is good!
Key Points
- File path camelizes to constant name; app/models/concerns is a root, not a namespace
- Acronyms need an inflection or you get an expected-file-to-define-constant NameError
- config.eager_load = true in production turns naming bugs into boot failures
- Run bin/rails zeitwerk:check in CI; never require your own app code
Q3What does rails new generate on Rails 8, and which defaults would you change for a production app?
BasicTooling
Answer
Rails 8 changed the default stack substantially. bin/rails new myapp gives you Propshaft instead of Sprockets for assets, importmap-rails for JavaScript so there is no Node build step, Hotwire (Turbo plus Stimulus) on the front end, SQLite as the default database, and the Solid trio wired in: Solid Queue for Active Job, Solid Cache for Rails.cache, and Solid Cable for Action Cable, all backed by the database rather than Redis. It also generates a Dockerfile, Kamal config in config/deploy.yml, Thruster in front of Puma inside the container, Brakeman and rubocop-rails-omakase, a GitHub Actions CI workflow, and a script/ directory for one-off scripts. Rails 8 also ships bin/rails generate authentication, which scaffolds a Session model, a Current attributes object, an Authentication concern and password reset mailers using has_secure_password, so many teams no longer reach for Devise on greenfield apps.
For a real production app in India I would usually swap SQLite for PostgreSQL with rails new myapp -d postgresql, because you almost always end up wanting concurrent writers, JSONB, partial indexes and managed backups on RDS or Cloud SQL. I would keep Solid Queue unless job throughput is high enough that the queue polling load becomes visible on the primary, in which case Sidekiq on Redis is still the safer choice. Everything else in the default stack is genuinely production sane, which is a real change from 2020.
# Postgres, Tailwind, RSpec-friendly skeleton
bin/rails new billing -d postgresql --css=tailwind --skip-test
# Rails 8 built-in auth (no Devise needed)
bin/rails generate authentication
# creates: app/models/session.rb, app/models/current.rb,
# app/controllers/concerns/authentication.rb,
# PasswordsController + mailer
# What backs the framework by default now
# config/queue.yml -> Solid Queue
# config/cache.yml -> Solid Cache
# config/cable.yml -> Solid Cable
# config/deploy.yml -> Kamal
Q4Explain schema.rb versus structure.sql and when db:schema:load beats db:migrate
BasicMigrations
Answer
After every migration Rails dumps the current schema so new machines can build the database without replaying history. The format is controlled by config.active_record.schema_format. The default :ruby writes db/schema.rb, a readable Ruby DSL.
The alternative :sql writes db/structure.sql using pg_dump, and you need it the moment your schema contains anything the DSL cannot express: PostgreSQL check constraints with expressions, triggers, materialized views, partial or expression indexes with unusual operator classes, custom enum types, or extensions with configuration. Teams discover this the painful way when someone adds a database trigger, schema.rb silently drops it, CI passes because CI loads schema.rb, and production diverges. For a fresh database, bin/rails db:schema:load (or bin/rails db:prepare, which loads the schema if the database does not exist and otherwise migrates) is the correct move.
Replaying five years of migrations with db:migrate is slow, and old migrations frequently reference model classes or columns that no longer exist, so they crash. That is also the argument for never referencing application models inside a migration: use execute or a throwaway class defined inside the migration file instead. In CI, load the schema rather than migrating, and add a check that git diff --exit-code db/schema.rb is clean after running migrations, which catches the developer who edited schema.rb by hand or forgot to commit the dump.
# config/application.rb
config.active_record.schema_format = :sql # needed for triggers, views, check constraints
# Fresh machine or CI
bin/rails db:prepare # create + load schema (+ migrate if needed) + seed
bin/rails db:schema:load # load db/schema.rb, skip migration history
# Never do this inside a migration
class BackfillPlans < ActiveRecord::Migration[8.0]
def up
# User.find_each { ... } <- breaks when the model changes later
execute <<~SQL
UPDATE users SET plan = 'free' WHERE plan IS NULL
SQL
end
end
Key Points
- schema_format :ruby loses triggers, views and expression constraints
- db:schema:load or db:prepare for fresh databases, not db:migrate
- Never reference application models from a migration
- Assert a clean schema dump in CI to catch hand edits
Q5How do strong parameters work, and what changed with params.expect in Rails 8?
BasicControllers
Answer
params in a controller is an ActionController::Parameters object, not a Hash. It deliberately refuses to be passed into a model writer until you declare which keys are allowed, which is Rails answer to mass assignment. The classic idiom is params.require(:user).permit(:name, :email), which raises ActionController::ParameterMissing (rendered as 400) when the user key is absent and silently drops anything not listed.
The silent dropping bites everyone at least once: a typo in a permit list means the field just never saves, with no error anywhere. Set config.action_controller.action_on_unpermitted_parameters = :raise in development and test so unexpected keys blow up loudly while you are still writing the code. Rails 8 added params.expect, which fixes a real security wart in the old pattern.
With require plus permit, a crafted payload where user is an array instead of a hash could slip through and produce surprising behaviour in nested attribute handling. expect asserts the shape as well as the keys, so params.expect(user: [:name, :email]) raises ActionController::ParameterMissing when user is not a hash with those permitted keys, and params.expect(comments: [[:body, :post_id]]) is the explicit way to say an array of hashes. Two rules for interviews: never call params.permit! in a controller that touches user data, and remember that permit on nested attributes needs the exact structure, including the _destroy key when you use accepts_nested_attributes_for with allow_destroy.
class UsersController < ApplicationController
def create
@user = User.new(user_params)
@user.save ? redirect_to(@user) : render(:new, status: :unprocessable_entity)
end
private
# Rails 8 style: asserts shape, not just keys
def user_params
params.expect(user: [:name, :email, :phone,
addresses_attributes: [[:id, :line1, :city, :_destroy]]])
end
# Pre-8 equivalent
# params.require(:user).permit(:name, :email, :phone,
# addresses_attributes: [:id, :line1, :city, :_destroy])
end
# config/environments/development.rb
config.action_controller.action_on_unpermitted_parameters = :raise
Q6Why is validates_uniqueness_of not enough, and how do you make uniqueness actually safe?
BasicValidations
Answer
validates :email, uniqueness: true issues a SELECT before the INSERT. Between that SELECT and the INSERT another request can insert the same value, so under any real concurrency you get duplicates. This is not theoretical: a double-clicked signup button on a slow 4G connection is enough to reproduce it, which is why it shows up constantly in Indian consumer apps.
The only correct fix is a unique index in the database, with the model validation kept for a friendly error message rather than for correctness. Add add_index :users, :email, unique: true, then rescue ActiveRecord::RecordNotUnique at the boundary, or use create_or_find_by, which attempts the INSERT first and falls back to a lookup when the index rejects it, unlike find_or_create_by which has the same race as the validation. Case sensitivity is the second trap. uniqueness: { case_sensitive: false } generates LOWER(email) = LOWER(?), which cannot use a plain btree index on email, so you either add a functional index on lower(email) or normalise the value before saving.
Rails 7.1 added normalizes for exactly this, so the attribute is downcased and stripped on assignment and in finders. Scoped uniqueness maps to a composite index: validates :slug, uniqueness: { scope: :account_id } needs add_index :posts, [:account_id, :slug], unique: true. Interviewers ask this to check whether you think about concurrency at all, and the wrong answer signals a candidate who has only worked on single-user development databases.
class User < ApplicationRecord
normalizes :email, with: ->(e) { e.strip.downcase } # Rails 7.1+
validates :email, presence: true, uniqueness: true # friendly message only
end
class AddUniqueEmailIndex < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :users, :email, unique: true, algorithm: :concurrently
end
end
# Safe under concurrency: INSERT first, fall back to SELECT
user = User.create_or_find_by(email: 'ravi@example.com')
# Or handle it explicitly
begin
User.create!(email: params[:email])
rescue ActiveRecord::RecordNotUnique
render json: { error: 'email already registered' }, status: :conflict
end
Key Points
- The validation is a SELECT then INSERT; it races by construction
- A unique database index is the only real guarantee
- create_or_find_by is race safe, find_or_create_by is not
- case_sensitive: false needs a functional index or normalizes
Q7When would you use after_commit instead of after_save, and why do callbacks cause production bugs?
BasicActiveRecord Callbacks
Answer
after_save runs inside the database transaction. The row is not visible to any other connection yet, and the transaction can still roll back. after_commit runs once the transaction has actually committed. That distinction decides a whole class of production bugs.
If you enqueue a background job from after_save, Sidekiq or Solid Queue can pick it up on another process before the commit lands, the worker queries for the record, finds nothing, and you get an intermittent ActiveRecord::RecordNotFound that only reproduces under load. Anything that touches the outside world (jobs, emails, webhooks, cache invalidation, search index updates, analytics events) belongs in after_commit. Use the on: option to scope it: after_commit :sync_to_crm, on: [:create, :update].
Note that after_commit does not run when the record is saved inside a transaction that later rolls back, which is exactly the behaviour you want, and that in transactional tests the outer transaction never commits, so you need self.use_transactional_tests = false or the test_after_commit behaviour Rails now provides by default. The broader callback problem is coupling. A model with eight callbacks touching mailers, jobs, other models and external APIs cannot be created in a test or a rake task without dragging the whole world in. The idiomatic 2026 answer is to keep callbacks for data integrity concerns that must always hold (normalising a phone number, maintaining a denormalised counter) and move workflow into an explicit service object or an Active Job triggered from the controller, so the sequence is readable in one place.
class Order < ApplicationRecord
# WRONG: worker may run before COMMIT and raise RecordNotFound
# after_save :notify
after_commit :notify, on: :create
after_commit :bust_cache, on: [:update, :destroy]
private
def notify
OrderConfirmationJob.perform_later(id)
end
def bust_cache
Rails.cache.delete("account/#{account_id}/order_totals")
end
end
# Need the commit hook only for a specific attribute change?
after_update_commit :reindex, if: -> { saved_change_to_status? }
Q8Explain belongs_to optional, dependent options and inverse_of with their production consequences
BasicAssociations
Answer
Since Rails 5, belongs_to is required by default, so belongs_to :account adds a presence validation and saving without an account raises a validation error. Add optional: true when the foreign key is genuinely nullable, for example a Comment that may or may not belong to a parent Comment. Candidates who learned Rails 4 habits often disable this globally via config.active_record.belongs_to_required_by_default, which is a smell worth flagging.
The dependent option on has_many decides what happens to children when the parent goes away, and each value behaves very differently. :destroy loads every child and runs its callbacks, which means deleting an account with 200k events fires 200k DELETE statements and can time out the request. :delete_all issues one DELETE and skips callbacks entirely, so any child that owns an S3 attachment or a Stripe subscription leaks. :destroy_async offloads the work to Active Job, which is usually the right answer for large collections. :nullify sets the foreign key to NULL, and :restrict_with_error blocks the delete. Importantly, dependent only runs through ActiveRecord, so a raw SQL delete or a database-level ON DELETE CASCADE bypasses it. inverse_of tells Rails that two associations are the same relationship in memory. Rails infers it automatically for standard names, but not when you pass a :foreign_key, a custom :class_name or a scope, and without it parent.children.first.parent loads a second copy of the parent from the database, so in-memory changes silently disappear and validations on the unsaved graph fail in confusing ways.
class Account < ApplicationRecord
has_many :orders, dependent: :destroy_async, inverse_of: :account
has_many :audit_events, dependent: :delete_all # no callbacks, no attachments
has_one :owner, -> { where(role: 'owner') },
class_name: 'User', inverse_of: :account # scope kills auto inverse_of
end
class Order < ApplicationRecord
belongs_to :account, inverse_of: :orders
belongs_to :coupon, optional: true # nullable FK
belongs_to :account_counter, counter_cache: :orders_count,
class_name: 'Account', foreign_key: :account_id
end
# Without inverse_of this is 2 queries and 2 objects:
account.orders.first.account.equal?(account) # => false
Key Points
- belongs_to is required by default since Rails 5; use optional: true deliberately
- dependent: :destroy loads every child, :delete_all skips callbacks, :destroy_async offloads
- dependent never fires for raw SQL or DB-level cascades
- Custom class_name, foreign_key or a scope disables automatic inverse_of
Q9Scopes versus class methods in ActiveRecord: what is actually different?
BasicQuery Interface
Answer
Functionally they are close, and scope is implemented as a class method under the hood, but three differences matter in interviews. First, a scope always returns a relation. If the body evaluates to nil, Rails substitutes all, so chaining never explodes.
A class method that returns nil from a conditional branch will blow up with NoMethodError on the next chained call, which is the single most common bug in hand-rolled filter methods. Second, scopes are chainable and composable by design, and they get merged sensibly with merge, or and and. Third, scopes accept lambdas so the body is evaluated at call time, not at class load time.
This is why scope :recent, -> { where('created_at > ?', 30.days.ago) } is correct and scope :recent, where('created_at > ?', 30.days.ago) is a bug that freezes the timestamp at boot and then quietly returns stale data for as long as the process lives. Under Puma with a long-lived process that can mean weeks. Use a class method when the logic needs multiple statements, early returns, or arguments that change the query structure rather than just its values. default_scope deserves a warning of its own: it applies to new records too, so default_scope { where(active: true) } makes Model.new.active default to true, it silently affects unscoped counts and joins across the app, and removing it later is a large refactor. Prefer an explicit named scope plus a Current-based filter, or PostgreSQL row level security for tenant isolation.
class Post < ApplicationRecord
# Correct: lambda, evaluated per call
scope :recent, -> { where(created_at: 30.days.ago..) }
scope :by_author, ->(id) { where(author_id: id) if id.present? } # nil -> all
# BUG: evaluated once at class load, timestamp frozen for the process lifetime
# scope :recent, where('created_at > ?', 30.days.ago)
def self.published_in(year)
return none if year.blank?
where(status: 'published', published_on: Date.new(year).all_year)
end
end
Post.recent.by_author(nil).published_in(2026).limit(20)
Q10How do you design routes in config/routes.rb beyond plain resources?
BasicRouting
Answer
resources :posts generates the seven RESTful routes. Beyond that, the tools interviewers expect you to know are member versus collection, nesting depth, constraints, namespaces and concerns. A member route takes an id (POST /posts/:id/publish), a collection route does not (GET /posts/search).
Nesting more than one level deep produces URLs like /accounts/1/posts/2/comments/3 that nobody wants to maintain, so use shallow: true, which nests only the index, new and create actions and leaves show, edit, update and destroy at the top level. Constraints filter which requests match: constraints(subdomain: 'api') for subdomain routing, or a lambda or class responding to matches? for anything dynamic, such as routing only authenticated admins to a Sidekiq or Mission Control dashboard. Use namespace :api do namespace :v1 for versioned APIs, which expects controllers under app/controllers/api/v1. scope module: gives you the controller namespace without the URL prefix, and scope path: gives the URL prefix without the module, a distinction that shows up in interviews. defaults: { format: :json } saves a suffix on every API path.
For debugging, bin/rails routes -g checkout greps the table and bin/rails routes -c OrdersController filters by controller. Finally, put the catch-all glob route last and be careful with match via: :all, which is the classic way people accidentally expose a destructive action to GET.
Rails.application.routes.draw do
concern :commentable do
resources :comments, only: [:index, :create], shallow: true
end
resources :posts, concerns: :commentable do
member { post :publish }
collection { get :search }
end
namespace :api do
namespace :v1, defaults: { format: :json } do
resources :orders, only: [:index, :show, :create]
end
end
constraints ->(req) { req.session[:admin_id].present? } do
mount MissionControl::Jobs::Engine, at: '/jobs'
end
get 'up' => 'rails/health#show', as: :rails_health_check
end
Key Points
- member routes take :id, collection routes do not
- shallow: true avoids three-level nested URLs
- namespace vs scope module: vs scope path: differ in URL and controller path
- bin/rails routes -g and -c for grep and controller filters
Q11Explain the difference between includes, preload, eager_load and joins
BasicQuery Performance
Answer
joins produces an INNER JOIN and is for filtering. It does not load the associated records, so posts = Post.joins(:comments).where(comments: { spam: true }) then calling post.comments still fires a query per post. It can also return duplicate rows when the association is one to many, which is why you often need .distinct. preload runs a separate query per association (SELECT * FROM comments WHERE post_id IN (...)) and never touches the join, so you cannot reference comments in a where clause on the parent query. eager_load forces a single LEFT OUTER JOIN with aliased columns and lets you filter on the joined table, at the cost of a wide result set that duplicates parent columns once per child. includes is the polymorphic one: Rails decides between preload and eager_load based on whether it detects a reference to the joined table, and references(:comments) forces the eager_load path when the condition is written as a raw SQL string that Rails cannot introspect.
The practical rule: use preload when you just want to avoid N+1 on a has_many, use eager_load or joins plus where when you are filtering, and use includes when you want Rails to pick. For a has_many with a large child count, two queries via preload almost always beat one giant join, because the join sends the parent row over the wire once per child. Rails 6.1 added strict_loading to make N+1 an exception instead of a silent latency problem, and turning it on for new models is a strong signal in a code review.
# N+1: 1 + N queries
Post.limit(20).each { |p| puts p.comments.size }
# 2 queries, no join
Post.preload(:comments).limit(20)
# 1 query, LEFT OUTER JOIN, filterable
Post.eager_load(:comments).where(comments: { approved: true })
# Filter only, association NOT loaded
Post.joins(:comments).where(comments: { spam: true }).distinct
# Raw SQL condition needs references() to force the join
Post.includes(:comments).references(:comments)
.where("comments.body ILIKE '%refund%'")
# Make N+1 raise instead of silently costing 300ms
class Post < ApplicationRecord
self.strict_loading_by_default = true
end
# ActiveRecord::StrictLoadingViolationError: Post is marked for strict_loading
Key Points
- joins filters, preload loads separately, eager_load joins and loads
- includes picks between them; references() forces eager_load for SQL strings
- eager_load duplicates parent columns per child row
- strict_loading turns N+1 into StrictLoadingViolationError
Q12How do Rails environments, encrypted credentials and config_for fit together?
BasicConfiguration
Answer
Rails loads config/application.rb for every environment and then config/environments/RAILS_ENV.rb on top of it. The environment file is where eager loading, caching, logging level, asset compilation and mailer delivery differ, and it is why an app can be fast to reload in development and fully eager loaded in production. Secrets live in encrypted credentials, not in .env files. bin/rails credentials:edit decrypts config/credentials.yml.enc with config/master.key, opens your editor, and re-encrypts on save.
Rails supports per-environment credentials with bin/rails credentials:edit --environment production, which writes config/credentials/production.yml.enc and a matching production.key. In production you never ship the key file, you set RAILS_MASTER_KEY as an environment variable, which is exactly what Kamal, ECS task definitions and Kubernetes secrets are for. Access values with Rails.application.credentials.dig(:razorpay, :key_secret).
For non-secret configuration that varies by environment, config_for reads a plain YAML file with environment keys and shared defaults, which is cleaner than scattering ENV lookups: Rails.application.config_for(:payments). Two habits matter in real teams. First, never read ENV directly deep inside a service class, wrap it once so a missing value fails at boot rather than at 2 AM inside a payment call. Second, remember credentials are committed to git in encrypted form, so a leaked master.key means every secret must be rotated, which is a common finding in security audits at Indian startups.
# Edit per-environment secrets
bin/rails credentials:edit --environment production
# writes config/credentials/production.yml.enc + production.key
# Read them
Rails.application.credentials.dig(:razorpay, :key_secret)
# config/payments.yml (non-secret, environment aware)
# shared:
# currency: INR
# retry_attempts: 3
# production:
# webhook_host: https://api.example.in
Rails.application.config_for(:payments).webhook_host
# Fail fast at boot
# config/initializers/00_required_env.rb
%w[RAILS_MASTER_KEY DATABASE_URL].each do |key|
raise "Missing required ENV #{key}" if ENV[key].blank?
end
Q13Why does User.all.each blow up memory, and what do find_each, in_batches and pluck do about it?
BasicQuery Performance
Answer
User.all.each loads every row into memory and instantiates a full ActiveRecord object for each one. An ActiveRecord instance is expensive: it carries the attribute hash, the raw type-cast values, the dirty tracking snapshot and association caches, so a table with two million rows can push a Puma worker past a gigabyte and get it killed by the container memory limit. find_each fixes this by batching. It orders by primary key, fetches 1000 rows at a time (configurable with batch_size), and yields records one by one, so peak memory stays proportional to the batch, not the table. find_in_batches yields the array instead of the record, and in_batches yields a relation, which is what you want when the operation is itself a bulk statement: User.in_batches(of: 5000).update_all(verified: false) issues one UPDATE per batch rather than one per row.
The critical caveat is that find_each ignores any order you set, because it imposes its own primary key ordering, and it silently ignores limit unless you use the Rails 7.1 support for it. When you only need a couple of columns, skip object instantiation entirely with pluck, which returns raw values straight from the result set. User.where(active: true).pluck(:id, :email) is dramatically cheaper than select(:id, :email).map. Use select when you still need model methods on the result, and remember that touching an unselected attribute on a partially selected record raises ActiveModel::MissingAttributeError.
# Loads everything: OOM risk on large tables
User.all.each { |u| u.recompute_score! }
# Batched: constant memory, ordered by primary key
User.find_each(batch_size: 500) { |u| u.recompute_score! }
# Bulk statement per batch, no callbacks, no instantiation
User.where(active: false).in_batches(of: 5_000) do |batch|
batch.update_all(archived_at: Time.current)
sleep 0.1 # let replicas catch up
end
# No ActiveRecord objects at all
ids = User.where(plan: 'gold').pluck(:id)
# MissingAttributeError waiting to happen
User.select(:id).first.email
Key Points
- find_each batches by primary key and ignores your ORDER BY
- in_batches yields a relation, ideal for update_all or delete_all per batch
- pluck skips object instantiation entirely
- select(:id) then reading another column raises ActiveModel::MissingAttributeError
Q14How does Active Job serialize arguments, and what breaks when you pass an object?
BasicBackground Jobs
Answer
Active Job is an adapter layer, not a queue. Your job class subclasses ApplicationJob, declares queue_as, and calls perform_later, and the configured adapter (Solid Queue by default on Rails 8, or Sidekiq, or :async in development) actually stores and runs it. Arguments must survive a round trip through JSON, so Active Job supports a fixed set of types: strings, numbers, booleans, nil, symbols, arrays, hashes with string or symbol keys, Time, Date, ActiveSupport::Duration, and any ActiveRecord object, which is serialized as a GlobalID URI like gid://myapp/User/42.
Anything else raises ActiveJob::SerializationError, and the usual offenders are a Struct, an uploaded file object, or a Ruby object from a gem. GlobalID has a consequence people miss: the worker deserializes by calling find, so if the record was deleted between enqueue and execution the job raises ActiveJob::DeserializationError. Add discard_on ActiveJob::DeserializationError when that is a legitimate outcome, for example a notification for a cancelled order.
The related trap is passing a large object graph as an argument to save a query. Do not. Pass the id, refetch inside perform, and you get the current state instead of a snapshot from when the job was enqueued, which matters when a job sits in the queue for ten minutes during a traffic spike. Use retry_on with a wait: :polynomially_longer backoff for transient failures like a payment gateway timeout, and perform_all_later to enqueue a batch in one round trip.
class InvoiceEmailJob < ApplicationJob
queue_as :mailers
retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
discard_on ActiveJob::DeserializationError
def perform(invoice_id)
invoice = Invoice.find(invoice_id) # refetch, do not trust a snapshot
return if invoice.cancelled? # idempotency guard
BillingMailer.with(invoice:).receipt.deliver_now
end
end
InvoiceEmailJob.perform_later(invoice.id)
InvoiceEmailJob.set(wait: 10.minutes, queue: :low).perform_later(invoice.id)
# One round trip for many jobs (Rails 7.1+)
ActiveJob.perform_all_later(ids.map { |id| InvoiceEmailJob.new(id) })
Q15How does Rails CSRF protection work, and how do you handle it for a JSON API?
BasicSecurity
Answer
ApplicationController inherits protect_from_forgery with: :exception by default in Rails apps that serve HTML. Rails puts a per-session authenticity token in the session cookie and embeds a masked copy in every form_with form and in a csrf-token meta tag in the layout. On any non-GET request, ActionController::RequestForgeryProtection unmasks the submitted token and compares it against the session.
A mismatch raises ActionController::InvalidAuthenticityToken, which renders as 422. The most common production version of this error has nothing to do with attacks: a page was cached by a CDN or by Rails fragment caching with the token baked in, the visitor gets someone else stale token, and the form fails. That is why the token is injected by JavaScript in modern setups and why you must never full-page cache a page containing a form without stripping the token.
UJS and Turbo read the meta tag and send the token in the X-CSRF-Token header automatically, so Hotwire apps need no special handling. For a pure JSON API authenticated with a bearer token, CSRF does not apply, because the browser does not attach an Authorization header automatically. Inherit from ActionController::API, which does not include the forgery module at all, or use protect_from_forgery with: :null_session on a mixed controller so an unverified request continues with an empty session rather than raising. Never sprinkle skip_before_action :verify_authenticity_token across controllers to silence errors on cookie-authenticated endpoints, because that is precisely the vulnerability the protection exists for.
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
end
# JSON API with bearer tokens: no CSRF module at all
class Api::V1::BaseController < ActionController::API
before_action :authenticate_bearer!
end
# Mixed controller: session dropped instead of raising
class WebhooksController < ApplicationController
protect_from_forgery with: :null_session
skip_before_action :verify_authenticity_token, only: :razorpay
before_action :verify_hmac_signature!, only: :razorpay
end
# Manual fetch from JS
# const token = document.querySelector('meta[name=csrf-token]').content
# fetch(url, { method: 'POST', headers: { 'X-CSRF-Token': token } })
Key Points
- Token lives in the session and in the csrf-token meta tag
- Mismatch raises ActionController::InvalidAuthenticityToken (422)
- Cached HTML containing a stale token is the usual production cause
- ActionController::API skips CSRF; replace it with signature verification on webhooks
Q16How do partials, layouts, content_for and collection rendering actually perform?
BasicViews
Answer
A layout wraps the view and yields to it, and content_for lets a view push named chunks upward (a page title, extra head tags, a sidebar) that the layout retrieves with yield :head. Partials are extracted view fragments named with a leading underscore, rendered with render 'shared/form' or render partial: 'row', locals: { order: order }. The performance detail that matters is collection rendering. render partial: 'orders/row', collection: @orders (or the shorthand render @orders) compiles the partial once and reuses it for every element, instead of the full render pipeline per row, which is measurably faster on a list of 200 items than @orders.each { render 'row', order: it }.
Combine it with cached: true and Rails performs a single multi-key cache read (read_multi) against the cache store instead of one round trip per row, which is the difference between 200 Redis calls and one. Prefer locals over instance variables in partials: a partial that reads @order is silently coupled to whichever controller set it, while a partial with explicit locals can be rendered from a Turbo Stream, a mailer or a job. Rails 7.1 added strict locals via a magic comment, which turns a missing or misspelled local into an error at render time rather than a nil that renders as blank. Finally, watch for N+1 inside partials, since the query is buried in the view it does not show up in a controller code review, only in the logs.
<%# app/views/orders/_row.html.erb %>
<%# locals: (order:, compact: false) -%> <!-- strict locals, Rails 7.1+ -->
<tr>
<td><%= order.number %></td>
<td><%= number_to_currency(order.total, unit: '₹') %></td>
</tr>
<%# index.html.erb: one compile, one multi-key cache read %>
<%= render partial: 'orders/row', collection: @orders, cached: true %>
<%# content_for pushes into the layout %>
<% content_for :head do %>
<link rel="canonical" href="<%= orders_url %>">
<% end %>
Q17What are Turbo Frames, Turbo Streams and Stimulus, and when do you reach for each?
BasicHotwire
Answer
Hotwire is the default front end in Rails 8 and interviewers expect you to distinguish its three pieces. Turbo Drive intercepts link clicks and form submissions, fetches the new page over fetch, and swaps the body without a full reload, which is why a Rails 8 app feels like an SPA with zero JavaScript written. Turbo Frames scope that behaviour to a region: wrap markup in turbo_frame_tag 'order_42' and any link or form inside it replaces only that frame, so an inline edit form or a modal needs no custom JS.
Frames are also lazy loadable with src and loading: :lazy, which is a clean way to defer an expensive sidebar out of the main request. Turbo Streams are the push side. A controller can respond with a .turbo_stream.erb template containing append, prepend, replace, update, remove or morph actions targeting DOM ids, and the same fragments can be broadcast over Action Cable with broadcast_replace_later_to from a model or job, giving you live updates without writing a WebSocket client.
Stimulus fills the remaining gap: sprinkles of behaviour that are genuinely client side, like a dropdown, a character counter or a Razorpay checkout handoff, wired through data-controller, data-action and targets rather than by querying the DOM yourself. Turbo 8 added page morphing, enabled with a turbo-refresh-method meta tag, which diffs the DOM on refresh so scroll position and focus survive. The rule of thumb: server rendering first, frames for regions, streams for pushes, Stimulus only for real client state.
<%# Inline edit: only this frame reloads %>
<%= turbo_frame_tag dom_id(order) do %>
<%= link_to 'Edit', edit_order_path(order) %>
<% end %>
<%# Lazy loaded region %>
<%= turbo_frame_tag 'recommendations', src: recommendations_path, loading: :lazy do %>
<p>Loading...</p>
<% end %>
# app/views/orders/create.turbo_stream.erb
<%= turbo_stream.prepend 'orders', partial: 'orders/row', locals: { order: @order } %>
<%= turbo_stream.update 'orders_count', @orders_count %>
# Broadcast from the model, rendered server side
class Order < ApplicationRecord
after_create_commit -> { broadcast_prepend_later_to account, target: 'orders' }
end
Key Points
- Turbo Drive: whole-page navigation without reloads
- Turbo Frames: scoped replacement plus lazy loading via src
- Turbo Streams: append/replace/remove, over HTTP response or Action Cable broadcast
- Stimulus: only for genuine client-side behaviour
Q18How does Rails.cache work, and what is Solid Cache doing differently from Redis?
BasicCaching
Answer
Rails.cache is a uniform interface over pluggable stores: :memory_store (per process, useless behind multiple Puma workers), :file_store, :mem_cache_store, :redis_cache_store, and Solid Cache, which is the Rails 8 default and stores entries in a database table. The workhorse is fetch, which reads the key and, on a miss, runs the block, writes the result and returns it. Always pass expires_in, because a cache without a TTL eventually becomes a correctness bug.
Values are serialized, so store plain data (hashes, arrays, strings, ids), not ActiveRecord objects, since a marshalled model from a previous deploy can fail to load after a schema change. Cache keys should be derived, not hand written. Every ActiveRecord object implements cache_key_with_version, which combines the class, id and updated_at, so [product, current_user.plan] as a key array invalidates automatically when the product is touched.
Solid Cache is interesting because it inverts a long-standing assumption. Instead of keeping a small cache in RAM, it uses cheap disk on the database server to hold a much larger cache, so hit rates go up even though each read is slightly slower than Redis. For most Rails apps the extra 1 to 2 milliseconds is irrelevant next to the queries it avoids, and the operational saving of not running a Redis cluster is real for small Indian teams. Where it does not fit is a very hot key read thousands of times per second, in which case Redis or a local in-process cache in front of Solid Cache is the right call.
# Derived key: invalidates when the record is touched
def dashboard_stats(account)
Rails.cache.fetch([account, 'dashboard_stats', 'v3'], expires_in: 10.minutes) do
{ orders: account.orders.count, revenue: account.orders.sum(:total) }
end
end
# Batch read: one round trip
Rails.cache.read_multi(*product_ids.map { |id| "product/#{id}/price" })
# config/environments/production.rb (Rails 8 default)
config.cache_store = :solid_cache_store
config.action_controller.perform_caching = true
# Delete a namespace of keys
Rails.cache.delete_matched("account/#{account.id}/*") # avoid on Redis in prod
Q19Explain nested transactions in ActiveRecord and why raise ActiveRecord::Rollback can silently lose data
IntermediateTransactions
Answer
ActiveRecord::Base.transaction opens a real database transaction and commits when the block returns, or rolls back when an exception escapes. The trap is nesting. By default a transaction block inside another transaction is not a new transaction: it joins the outer one.
So if the inner block raises ActiveRecord::Rollback, Rails swallows the exception (that is what Rollback is for, it rolls back without propagating) but the outer transaction has nothing to roll back to, so the outer work commits and the inner work commits with it. Developers write this expecting the inner section to be undone and find the opposite in production. To get a genuinely independent nested unit you must pass requires_new: true, which issues a SAVEPOINT and allows a partial rollback.
Second gotcha: any exception other than ActiveRecord::Rollback propagates and rolls back everything, including exceptions from your own code, which is usually what you want but does mean a rescue inside the transaction block that swallows the error will commit a half-finished state. Third: never do network calls inside a transaction. A payment gateway call that takes eight seconds holds row locks for eight seconds, and under load that turns into lock contention and ActiveRecord::LockWaitTimeout across the app.
Take the money outside the transaction, then persist the result inside a short one. Rails 7.1 also made it safer to reason about commit callbacks inside transactions and added transaction isolation levels via transaction(isolation: :serializable) for the cases where you genuinely need it.
# Inner rollback does NOT undo inner work: it joins the outer transaction
ApplicationRecord.transaction do
order.update!(status: 'paid')
ApplicationRecord.transaction do
ledger.update!(balance: ledger.balance - order.total)
raise ActiveRecord::Rollback # swallowed, ledger change still commits
end
end
# Correct: SAVEPOINT gives a real nested unit
ApplicationRecord.transaction do
order.update!(status: 'paid')
ApplicationRecord.transaction(requires_new: true) do
ledger.update!(balance: ledger.balance - order.total)
raise ActiveRecord::Rollback # only the ledger change is undone
end
end
# Never hold locks across a network call
charge = Razorpay::Payment.capture(order.payment_id) # outside
ApplicationRecord.transaction { order.update!(status: 'paid', charge_id: charge.id) }
Key Points
- Nested transaction blocks join the parent unless requires_new: true
- requires_new issues a SAVEPOINT, enabling partial rollback
- ActiveRecord::Rollback is swallowed and does not propagate upward
- External HTTP inside a transaction holds row locks and causes contention
Q20Optimistic versus pessimistic locking in Rails: how do you prevent a double refund?
IntermediateConcurrency
Answer
Optimistic locking is built in and free. Add an integer column named lock_version with default 0, and ActiveRecord appends AND lock_version = ? to every UPDATE and increments it. If another process updated the row first, zero rows match and Rails raises ActiveRecord::StaleObjectError, which you rescue by reloading and retrying or by showing the user a conflict message.
It is the right default for user-facing edit forms where conflicts are rare and a retry is acceptable. Pessimistic locking takes an actual database row lock: record.lock! issues SELECT ... FOR UPDATE, and with_lock wraps a transaction plus the lock in one call.
Any other transaction touching that row blocks until yours commits. This is the correct tool for money: a refund handler must read the balance, decide, and write while holding the row, otherwise two concurrent webhook deliveries from a payment gateway each read a refundable balance and each issue a refund. Two rules keep pessimistic locking safe.
First, keep the locked section tiny and never call an external API inside it. Second, always acquire locks in a consistent order across the codebase (for example ascending by primary key) or two transactions locking A then B and B then A will deadlock, and PostgreSQL will kill one with ActiveRecord::Deadlocked. Set a lock_timeout in database.yml so a stuck lock fails fast instead of piling up Puma threads. For work that must run once across the whole fleet, a PostgreSQL advisory lock is often a better fit than locking a row.
class AddLockVersionToOrders < ActiveRecord::Migration[8.0]
def change
add_column :orders, :lock_version, :integer, null: false, default: 0
end
end
# Optimistic: rescue and retry
begin
order.update!(status: 'shipped')
rescue ActiveRecord::StaleObjectError
order.reload
retry if (tries = tries.to_i + 1) < 3
end
# Pessimistic: SELECT ... FOR UPDATE around the read-decide-write
def refund!(amount)
order.with_lock do
raise InsufficientBalance if order.refundable_amount < amount
order.refunds.create!(amount: amount)
order.update!(refundable_amount: order.refundable_amount - amount)
end
end
# config/database.yml
# variables:
# lock_timeout: 5000
# statement_timeout: 15000
Key Points
- lock_version column enables optimistic locking and StaleObjectError
- with_lock = transaction + SELECT FOR UPDATE, the right tool for money
- Acquire locks in a consistent order or you get ActiveRecord::Deadlocked
- Set lock_timeout and statement_timeout so contention fails fast
Q21How do you add a column and an index to a 200 million row table without downtime?
IntermediateMigrations
Answer
Every migration is an ACCESS EXCLUSIVE lock waiting to happen, and on a hot table the danger is not the migration duration but the lock queue: a blocked DDL statement blocks every subsequent read behind it, so a five second migration can take the whole app down. Start by setting a short lock_timeout in the migration so it gives up rather than queueing, and retry. Adding a nullable column is cheap on PostgreSQL 11 and later, and adding a column with a constant DEFAULT is also cheap because the default is stored in the catalog rather than rewritten into every row.
Adding a column with a volatile default or a NOT NULL constraint on existing data is not cheap, so the safe sequence is: add the column nullable, backfill in batches with in_batches and a sleep between batches to let replicas catch up, add a NOT NULL check constraint as NOT VALID, then VALIDATE CONSTRAINT which takes only a SHARE UPDATE EXCLUSIVE lock. Indexes must be created with algorithm: :concurrently, which requires disable_ddl_transaction! because CREATE INDEX CONCURRENTLY cannot run inside a transaction, and it can leave an invalid index behind if it fails, so check for one before retrying. Removing a column is the reverse problem: ActiveRecord caches the column list, so a deploy that drops the column while old processes are still running raises errors on every INSERT.
Add the column name to self.ignored_columns, deploy, then drop it in a follow-up deploy. The strong_migrations gem encodes all of these rules and fails CI with the safe alternative spelled out, which is what most serious Rails teams run.
class AddCurrencyToOrders < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def up
safety_assured { execute "SET lock_timeout = '5s'" }
add_column :orders, :currency, :string, default: 'INR' # catalog default, no rewrite
add_index :orders, [:account_id, :created_at], algorithm: :concurrently
end
end
# Backfill separately, in batches, off the deploy path
Order.where(currency: nil).in_batches(of: 10_000) do |batch|
batch.update_all(currency: 'INR')
sleep 0.2
end
# Two-deploy column removal
class Order < ApplicationRecord
self.ignored_columns += %w[legacy_status] # deploy 1
end
# deploy 2: remove_column :orders, :legacy_status
Key Points
- A blocked DDL statement blocks every query queued behind it
- add_index needs algorithm: :concurrently plus disable_ddl_transaction!
- NOT NULL via a NOT VALID check constraint then VALIDATE avoids a long lock
- Removing a column needs ignored_columns and two deploys
Q22Compare single table inheritance, polymorphic associations and delegated types
IntermediateData Modelling
Answer
STI puts several classes in one table and discriminates with a type column: class PremiumUser < User. It is cheap to query across all subtypes and it keeps associations simple, but every subtype-specific column has to live in the shared table as a nullable column, so the table degenerates into a wide sheet of mostly NULLs once the subtypes diverge. Use STI only when the subclasses differ in behaviour, not in data.
Polymorphic associations invert the problem: comment belongs_to :commentable, polymorphic: true stores commentable_type and commentable_id, letting one comments table attach to posts, orders or tickets. The cost is that you cannot have a foreign key constraint, so referential integrity is your problem, and joins across the polymorphic edge are awkward because the target table is only known at runtime. Always index [commentable_type, commentable_id] together.
Delegated types, added in Rails 6.1, are the middle path: a shared Entry record holds the common columns and a polymorphic entryable pointer to a per-type table that holds the specific columns. You get real columns with real constraints per type, plus a single table to order and paginate over. Declare delegated_type :entryable, types: %w[Message Comment] and Rails generates entryable_class, message?, comment? and scoped helpers. In interviews the useful line is that STI trades schema cleanliness for query simplicity, polymorphic trades integrity for flexibility, and delegated types buy back both at the cost of one extra join.
# STI: one table, type column, nullable subtype columns
class Notification < ApplicationRecord; end
class SmsNotification < Notification; end # needs a 'type' string column
# Polymorphic: no FK constraint, index both columns together
class Comment < ApplicationRecord
belongs_to :commentable, polymorphic: true
end
add_index :comments, [:commentable_type, :commentable_id]
# Delegated types: shared row + typed table
class Entry < ApplicationRecord
delegated_type :entryable, types: %w[Message Attachment], dependent: :destroy
end
module Entryable
extend ActiveSupport::Concern
included { has_one :entry, as: :entryable, touch: true }
end
class Message < ApplicationRecord
include Entryable # messages table owns subject, body
end
Entry.order(created_at: :desc).includes(:entryable).limit(50)
Q23Explain Russian doll caching and how cache keys get invalidated correctly
IntermediateCaching
Answer
Russian doll caching nests fragment caches so an inner change invalidates only its own fragment plus the outer wrappers, not the whole page. The mechanism is the cache key. Rails computes a key from the record class, id and updated_at (cache_key_with_version), so touching a record changes its key and the old fragment is orphaned rather than deleted, which is why this pattern needs no explicit expiry logic.
The piece candidates forget is belongs_to :post, touch: true. Without it, updating a comment does not change the post updated_at, so the outer post fragment keeps serving stale HTML that no longer contains the new comment. With touch: true the write cascades upward and each level re-renders.
Rails also mixes a digest of the template source into the key, so editing the ERB automatically busts the fragment, which removes a whole class of deploy-time staleness bugs. Two production considerations. First, recycling: because expired entries are never deleted, your cache store fills with orphans and relies on LRU eviction, which is fine on Redis with an eviction policy and fine on Solid Cache, which trims by size, but a misconfigured store with noeviction returns OOM errors on write.
Second, the thundering herd: when a very hot fragment expires, every concurrent request recomputes it at once. Rails supports race_condition_ttl on fetch, which lets one request rebuild while others serve the slightly stale value. For collections, render with cached: true so the whole list is fetched with one read_multi.
class Comment < ApplicationRecord
belongs_to :post, touch: true # cascades updated_at upward
end
<%# outer fragment: key includes post.updated_at + template digest %>
<% cache post do %>
<h1><%= post.title %></h1>
<%= render partial: 'comments/comment', collection: post.comments, cached: true %>
<% end %>
# Hot key protection: one rebuilder, others serve stale
Rails.cache.fetch('homepage/trending', expires_in: 5.minutes,
race_condition_ttl: 10.seconds) do
Product.trending.limit(20).to_a
end
# Manual versioning when the shape of the value changes
Rails.cache.fetch(["pricing", "v4", plan.id], expires_in: 1.hour) { compute(plan) }
Key Points
- Keys come from updated_at plus a template digest, so entries are orphaned, not deleted
- belongs_to touch: true is mandatory for the outer fragment to invalidate
- race_condition_ttl protects hot keys from a thundering herd
- Cache store must have an eviction policy or writes eventually fail
Q24How do you structure a Rails test suite, and what actually makes it flaky?
IntermediateTesting
Answer
Rails ships Minitest with fixtures; most Indian product teams use RSpec with FactoryBot, and interviewers accept either as long as you can defend the trade-offs. Fixtures load once into the database before the suite and are fast but global, so a change for one test can break another. Factories build objects per test and are readable but slow if every factory eagerly creates five associations, which is the number one reason a suite that started at 40 seconds takes 12 minutes a year later.
Use build_stubbed where you do not need persistence, use create sparingly, and keep associations out of the default factory. Each test runs inside a transaction that is rolled back, which keeps tests isolated and fast. The classic flakiness sources are all leaks around that boundary.
Time-dependent assertions: freeze the clock with travel_to or the timecop pattern instead of comparing against Time.current. Ordering dependence: run with a random seed and fix whatever breaks, because a test that only passes after another test ran is a bug in your setup. External HTTP: stub with WebMock and fail the suite on any unstubbed request, otherwise CI depends on a payment sandbox being up.
System tests with a real browser are the worst offenders, because Capybara and the app run in different threads and assertions race the JavaScript; use Capybara matchers (have_text, have_selector) that retry rather than page.text checks, and never sleep. Finally, parallelize(workers: :number_of_processors) speeds things up but requires each worker to get its own database, and any global state such as a class-level memoised client will bite you.
# test/test_helper.rb
class ActiveSupport::TestCase
parallelize(workers: :number_of_processors)
fixtures :all
end
# spec/rails_helper.rb
RSpec.configure do |config|
config.use_transactional_fixtures = true
config.order = :random
config.include FactoryBot::Syntax::Methods
end
WebMock.disable_net_connect!(allow_localhost: true)
# Deterministic time
travel_to Time.zone.parse('2026-04-01 09:30') do
assert_equal 'Q1', Invoice.new.fiscal_quarter
end
# Fast: no database write
user = build_stubbed(:user, plan: 'gold')
assert user.premium?
Q25How do you make a background job safe to run twice, and what retry policy would you set?
IntermediateBackground Jobs
Answer
Every queue you will use in Rails is at-least-once, not exactly-once. Solid Queue claims a job with SELECT ... FOR UPDATE SKIP LOCKED and Sidekiq pops it off a Redis list, but in both cases a worker that dies after doing the work and before acknowledging leaves the job to be retried.
So idempotency is a design requirement, not an optimisation. Three patterns cover most cases. First, a natural idempotency key: before doing anything, check whether the effect already exists (return if invoice.paid?, or a unique index on payment_reference so the second insert raises RecordNotUnique and you swallow it).
Second, a state machine with a guarded transition, so the job only acts when the record is in the expected state and the transition itself is done under a row lock. Third, an explicit dedup key stored in the cache or a table with a TTL, which is what unique-job plugins do. On retries, distinguish transient from permanent. retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 6 is right for a flaky gateway. discard_on ActiveJob::DeserializationError and discard_on ActiveRecord::RecordNotFound are right for work whose subject no longer exists.
Anything that fails permanently should end up somewhere visible: Sidekiq has the dead set and its web UI, Solid Queue has a failed executions table and the Mission Control Jobs engine. The failure mode interviewers really want to hear about is the retry storm: a downstream service goes down, thousands of jobs retry on the same schedule, and when the service recovers it is immediately hammered. Use jittered exponential backoff and a circuit breaker rather than a fixed wait.
class CapturePaymentJob < ApplicationJob
queue_as :payments
retry_on Faraday::TimeoutError, wait: :polynomially_longer, attempts: 6, jitter: 0.3
discard_on ActiveRecord::RecordNotFound
def perform(order_id)
order = Order.find(order_id)
order.with_lock do
return if order.captured? # guard 1: state check under lock
receipt = Gateway.capture!(order.payment_id,
idempotency_key: "order-#{order.id}") # guard 2
order.update!(status: 'captured', receipt_id: receipt.id)
end
end
end
# Guard 3: let the database reject the duplicate
add_index :receipts, :gateway_reference, unique: true
Key Points
- Every adapter is at-least-once; design for duplicate execution
- Guard with a state check under a lock, a unique index, or a dedup key
- retry_on for transient errors, discard_on for vanished subjects
- Jittered backoff avoids a retry storm when a dependency recovers
Q26Where can SQL injection and XSS still happen in a Rails app despite the built-in protections?
IntermediateSecurity
Answer
Rails escapes HTML in ERB by default and parameterises queries when you use the hash or placeholder forms, so both vulnerability classes require you to opt out. For SQL, the opt-out is string interpolation into any method that accepts raw SQL: where("name = '#{params[:q]}'"), order(params[:sort]), pluck(params[:column]), group, having, joins with a raw string, and find_by_sql. Use the placeholder forms instead: where('name = ?', params[:q]) or where(name: params[:q]).
For dynamic ordering, never pass user input through, whitelist it against a known list of columns and directions, or use Arel. Rails 6.1 added sanitize_sql_for_order and made unsafe raw order values raise ActiveRecord::UnknownAttributeReference unless you explicitly allow them with Arel.sql, which is a useful safety net worth mentioning. For XSS, the opt-outs are html_safe, raw, and any helper that renders unescaped content: sanitize with a permissive allowlist, content_tag with escape: false, and rendering user-supplied Markdown or HTML.
The subtle one is a link_to with a user-supplied href, because javascript: URLs still execute; validate the scheme. Also watch for user data interpolated into a JavaScript block in a view, where HTML escaping is the wrong escaping entirely; use to_json (which Rails makes JS-safe) or a data attribute read by Stimulus. Run Brakeman in CI, which Rails now generates by default, and treat each warning as a real finding rather than noise. Two more that show up in Indian fintech reviews: params.permit! anywhere near a model, and a webhook endpoint with CSRF skipped but no HMAC signature verification.
# SQL injection
Order.where("reference = '#{params[:ref]}'") # vulnerable
Order.where('reference = ?', params[:ref]) # safe
Order.where(reference: params[:ref]) # safest
# Dynamic ORDER BY: whitelist, never interpolate
SORTS = { 'newest' => { created_at: :desc }, 'total' => { total: :desc } }.freeze
Order.order(SORTS.fetch(params[:sort], created_at: :desc))
# XSS
<%= @comment.body %> <!-- escaped -->
<%= raw @comment.body %> <!-- vulnerable -->
<%= sanitize @comment.body, tags: %w[b i a], attributes: %w[href] %>
# Safe data handoff to JS
<div data-controller="chart" data-chart-series-value="<%= @series.to_json %>"></div>
# CI
# $ bundle exec brakeman --no-pager -w2 --exit-on-warn
Q27Fat model, skinny controller is not enough. Where does business logic actually go?
IntermediateArchitecture
Answer
Rails gives you models, controllers and views, and the honest answer is that a real application needs a fourth place. The failure mode is a User model at 900 lines with fifteen callbacks, six concerns and methods that send emails, so nothing can be tested or changed in isolation. The 2026 consensus is a layered approach.
Keep in the model anything that is genuinely about that row: validations, associations, scopes, derived attributes and normalisation. Put multi-step workflows that coordinate several models plus side effects into plain Ruby objects, usually a class with a single public call method living in app/services or app/operations, which are just autoload paths, not framework features. Use ActiveSupport::Concern for behaviour that is genuinely shared across models (Archivable, Sluggable), and resist using it merely to shorten a long file, because moving 300 lines into app/models/concerns does not reduce coupling, it just hides it.
Form objects (an ActiveModel::Model class with its own validations) handle multi-model forms far better than accepts_nested_attributes_for. Query objects encapsulate a gnarly scope chain and keep the model free of reporting concerns. Serializers or presenters keep formatting out of the model.
The signal interviewers look for is whether you can articulate why: a service object is testable without HTTP, has an explicit dependency list, and reads top to bottom, so an on-call engineer can follow the order of operations. Also mention ActiveSupport::CurrentAttributes for request-scoped context like current tenant, and its caveat that it must be reset between requests, which the executor does for you but a raw thread does not.
# app/services/orders/place.rb
module Orders
class Place
Result = Struct.new(:ok?, :order, :error)
def initialize(account:, cart:, gateway: Gateway)
@account, @cart, @gateway = account, cart, gateway
end
def call
charge = @gateway.charge!(@cart.total, idempotency_key: @cart.token)
order = nil
ApplicationRecord.transaction do
order = @account.orders.create!(total: @cart.total, charge_id: charge.id)
@cart.line_items.each { |li| order.line_items.create!(li.attributes.except('id')) }
@cart.destroy!
end
OrderConfirmationJob.perform_later(order.id)
Result.new(true, order, nil)
rescue Gateway::Declined => e
Result.new(false, nil, e.message)
end
end
end
result = Orders::Place.new(account: current_account, cart: @cart).call
Q28How do you build a versioned JSON API in Rails, and what do you use for serialization?
IntermediateAPI Design
Answer
Start with rails new --api, which drops the view layer, cookies and CSRF middleware and makes ActionController::API the base class, so a request carries noticeably less middleware overhead. Version by namespace in routes (namespace :api do namespace :v1) so controllers live under app/controllers/api/v1 and a v2 can exist alongside without touching v1, which matters when a mobile app in the field cannot be forced to upgrade. Header-based versioning via an Accept header and a routing constraint is the alternative, and it is cleaner in theory but harder to debug and to cache, so most teams use the path.
For serialization the practical options in 2026 are jbuilder (ships with Rails, template based, easy to read, slower on very large payloads), alba or oj-backed serializers (fast, plain Ruby classes), and ActiveModel::Serializers (widely used in older codebases, effectively in maintenance). Whatever you pick, keep it out of the model and be explicit about which attributes go over the wire, because implicit as_json leaks new columns the day someone adds them. Pagination should be keyset based (WHERE id < last_id ORDER BY id DESC LIMIT 25) rather than OFFSET on large tables, since OFFSET 100000 makes the database scan and discard a hundred thousand rows.
Add ETag or Last-Modified support via fresh_when and stale? so clients on patchy Indian mobile networks can revalidate cheaply with a 304. Standardise your error envelope, and handle exceptions centrally with rescue_from in a base controller so a RecordNotFound never leaks a stack trace.
class Api::V1::BaseController < ActionController::API
rescue_from ActiveRecord::RecordNotFound do |e|
render json: { error: { code: 'not_found', message: e.message } }, status: :not_found
end
rescue_from ActiveRecord::RecordInvalid do |e|
render json: { error: { code: 'invalid', details: e.record.errors.to_hash } },
status: :unprocessable_entity
end
end
class Api::V1::OrdersController < Api::V1::BaseController
def index
scope = current_account.orders.includes(:line_items).order(id: :desc).limit(25)
scope = scope.where('id < ?', params[:before]) if params[:before] # keyset pagination
@orders = scope.to_a
fresh_when(@orders) # ETag + 304 on revalidation
end
end
Key Points
- ActionController::API removes cookies, CSRF and the view layer
- Path namespacing is the pragmatic versioning strategy
- Keyset pagination instead of OFFSET on large tables
- Central rescue_from plus a stable error envelope
Q29Show how merge, or, unscope, load_async and explain(:analyze) change a query
IntermediateQuery Interface
Answer
An ActiveRecord::Relation is lazy: it builds an Arel tree and only executes on load, to_a, each, first or any enumerable call. Knowing what is composable saves a lot of duplicated SQL. merge combines two relations and is how you apply a scope defined on the associated model: Order.joins(:account).merge(Account.active) keeps the active definition in one place. Be careful, merge lets the right side win on conflicting where clauses on the same column, which surprises people. or requires both sides to have the same joins, includes and references structure, otherwise it raises ArgumentError telling you exactly which values are incompatible. unscope removes a previously applied clause, which is how you escape a default_scope or drop an ordering before a count. rewhere replaces rather than ANDs. load_async, added in Rails 7, dispatches the query to a background thread pool so two independent queries in a controller overlap instead of running serially; it needs config.active_record.async_query_executor set, and it only helps when the queries are genuinely independent and the pool has spare connections.
For diagnosis, relation.to_sql prints the SQL without running it, and relation.explain(:analyze, :buffers) on Rails 7.1 and later runs EXPLAIN with those options against PostgreSQL, which is how you prove an index is being used rather than guessing. Also useful: annotate adds an SQL comment so a slow query log entry can be traced back to a code location, and query_log_tags does this automatically for controller and job names.
# Compose scopes across models
Order.joins(:account).merge(Account.active).merge(Account.in_region('south'))
# or requires matching structure on both sides
Order.where(status: 'paid').or(Order.where(total: 10_000..))
# Escape an inherited ordering or default_scope
Order.order(:created_at).unscope(:order).count
Order.where(status: 'paid').rewhere(status: 'refunded').to_sql
# Overlap two independent queries (Rails 7+)
def dashboard
@orders = current_account.orders.recent.limit(20).load_async
@tickets = current_account.tickets.open.limit(20).load_async
end
# Prove the index is used
puts Order.where(account_id: 7, created_at: 1.week.ago..).explain(:analyze, :buffers)
# Trace slow SQL back to code
Order.annotate('dashboard#index').to_sql
Q30How do you instrument a Rails app so you can debug a p95 latency regression?
IntermediateObservability
Answer
Rails publishes structured events through ActiveSupport::Notifications, and every framework layer emits them: process_action.action_controller with view and database time, sql.active_record with the SQL, payload name and binds, render_template.action_view, perform.active_job, cache_read.active_support, deliver.action_mailer. You can subscribe to any of them and ship the numbers to your metrics backend, which is exactly what the Datadog, New Relic, OpenTelemetry and SignOz integrations do underneath. Knowing this is what separates someone who reads dashboards from someone who can add a missing one.
In practice the setup is: structured JSON logs with a request id (ActionDispatch::RequestId already sets it, use lograge or the Rails 8 structured logging setup so one request equals one log line with duration, db, view, status and controller#action), OpenTelemetry auto-instrumentation for traces across HTTP, ActiveRecord and Active Job, and config.active_record.query_log_tags_enabled = true so every SQL statement carries a comment naming the controller or job that issued it, which turns pg_stat_statements from anonymous SQL into attributable SQL. For a p95 regression specifically, the sequence is: confirm from the APM whether the extra time is in db, view or Ruby; if db, check pg_stat_statements for a plan change or a new N+1; if view, look for a partial rendered in a loop without collection caching; if neither, profile Ruby with stackprof or rack-mini-profiler in a staging environment. Do not skip the boring check first, a deploy that changed a scope or added an includes is the most common cause.
# Ship a custom metric from a framework event
ActiveSupport::Notifications.subscribe('process_action.action_controller') do |event|
StatsD.timing('http.duration', event.duration,
tags: ["action:#{event.payload[:controller]}##{event.payload[:action]}",
"status:#{event.payload[:status]}"])
StatsD.timing('http.db_ms', event.payload[:db_runtime].to_f)
end
# Attribute SQL back to code in pg_stat_statements
# config/application.rb
config.active_record.query_log_tags_enabled = true
config.active_record.query_log_tags = [:application, :controller, :action, :job]
# SELECT ... /*application:Billing,controller:orders,action:index*/
# Your own instrumented block
ActiveSupport::Notifications.instrument('gateway.capture', order_id: order.id) do
Gateway.capture!(order.payment_id)
end
Key Points
- Every layer emits ActiveSupport::Notifications events you can subscribe to
- One structured log line per request with db, view and total runtime
- query_log_tags makes pg_stat_statements attributable to controllers and jobs
- Split the regression into db, view and Ruby time before optimising anything
Q31Why does Time.now quietly corrupt Indian daily reports, and how do config.time_zone and the database interact?
IntermediateTime Zones
Answer
Rails keeps two zones in play. config.active_record.default_timezone is :utc by default, so every timestamp is written to and read from the database in UTC. config.time_zone decides what Time.zone returns and how ActiveRecord casts a timestamp on the way out. Time.now and Date.today ignore both and read the operating system clock, so inside a UTC container they return UTC while your users are on IST, which is UTC+5:30. The classic symptom is a daily revenue report grouped on created_at that splits the Indian day at 5:30 AM, so every order placed between midnight and 5:30 lands in the previous bucket and the totals never match what the ops team counted by hand.
Use Time.current, Time.zone.now, Date.current, Time.zone.parse and Time.zone.at everywhere, and enable the Rails/TimeZone RuboCop cop so review catches Time.now automatically. Setting config.time_zone = 'Asia/Kolkata' makes reads come back as IST TimeWithZone objects while storage stays UTC, which is what an India-only product usually wants. Grouping by an Indian calendar day in SQL needs an explicit conversion with AT TIME ZONE, and a plain btree index on created_at cannot serve that expression, so add an expression index if the report is hot.
For per-user zones, wrap the request in Time.use_zone inside an around_action. Time.zone is thread local, so a background job never inherits the request zone and neither does a thread you spawn: set it at the top of perform. One last detail interviewers like: t.timestamps generates timestamp without time zone on PostgreSQL, so if a Python service or a BI tool also writes to that table the UTC convention is application-only, which is why many teams switch those columns to timestamptz.
# config/application.rb
config.time_zone = 'Asia/Kolkata' # what Time.zone returns; storage stays UTC
Time.now # => 2026-08-12 04:15:00 +0000 (container clock, wrong)
Time.current # => Wed, 12 Aug 2026 09:45:00.000 IST +05:30
Date.today # server zone
Date.current # Time.zone
# Group by an Indian calendar day, not a UTC one
Order.group("date_trunc('day', created_at AT TIME ZONE 'UTC' AT TIME ZONE 'Asia/Kolkata')")
.sum(:total)
# Per-request zone override
around_action { |_c, action| Time.use_zone(current_user.time_zone, &action) }
# Jobs do NOT inherit the request zone
def perform(id)
Time.use_zone('Asia/Kolkata') { build_statement(id) }
end
Key Points
- Storage is UTC; config.time_zone only changes casting and Time.zone
- Time.now and Date.today read the container clock and ignore both settings
- AT TIME ZONE conversions need an expression index to stay fast
- Time.zone is thread local, so jobs and spawned threads start at the default
Q32How does Active Storage handle uploads and variants, and what do you configure before production?
IntermediateActive Storage
Answer
Active Storage adds three tables: active_storage_blobs holds file metadata and the service key, active_storage_attachments is a polymorphic join to your models, and active_storage_variant_records tracks generated variants. has_one_attached :avatar and has_many_attached :documents give you the API, and config/storage.yml defines the backing service (Disk, S3, GCS, Azure, or mirror while you migrate between them). Direct uploads are the first production setting to turn on: the JS client requests a signed URL from /rails/active_storage/direct_uploads, PUTs the bytes straight to S3, and submits only the signed blob id with the form. Without it a 20 MB upload on a patchy mobile connection occupies a Puma thread for the entire transfer, which is a cheap way to exhaust a worker.
Variants are lazy and processed on first request unless you declare them with preprocessed: true. Set config.active_storage.variant_processor = :vips, because libvips uses dramatically less memory than MiniMagick on large JPEGs, and remember that PDF and video previews shell out to poppler and ffmpeg, which must be installed in the container. That is the classic works-locally-fails-in-Docker bug.
Rendering a list of records with attachments is an N+1 by default, so use with_attached_avatar, which preloads the attachment and its blob. Decide between redirect mode (a 302 through your app to an expiring service URL, cheap but not CDN friendly) and proxy mode, which streams through Rails so CloudFront can cache it. Finally, Active Storage does not validate content type for you, purge runs as a background job, and deleting rows with raw SQL leaves orphan blobs, so schedule a sweep over ActiveStorage::Blob.unattached.
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_limit: [200, 200], preprocessed: true
end
validate :avatar_type
private
def avatar_type
return unless avatar.attached?
errors.add(:avatar, 'must be an image') unless avatar.content_type.start_with?('image/')
end
end
# config/application.rb
config.active_storage.variant_processor = :vips
config.active_storage.resolve_model_to_route = :rails_storage_proxy # CDN cacheable
<%= form.file_field :avatar, direct_upload: true %> <%# bytes never touch Puma %>
User.with_attached_avatar.limit(50) # preloads avatar_attachment: :blob
# Sweep orphans left by raw deletes
ActiveStorage::Blob.unattached.where(created_at: ..1.week.ago).find_each(&:purge_later)
Q33How do you size Puma workers, threads and the ActiveRecord pool for a 4 vCPU container?
IntermediatePerformance
Answer
Puma is a hybrid: workers are forked processes, and each worker runs a thread pool sized by the threads directive. Because CRuby holds a global VM lock, only one thread per process executes Ruby at a time, so processes are how you use more CPU and threads are how you overlap waiting on IO. A reasonable starting point on 4 vCPUs is 4 workers with 3 to 5 threads each, then measure: push threads higher and you mostly buy latency variance, because a slow request now delays the other threads sharing that worker's GVL.
Every thread that runs a query needs a connection, so the database.yml pool must be at least the max thread count, per process. The generated config drives both from RAILS_MAX_THREADS for exactly that reason, and when someone raises threads without raising pool you get ActiveRecord::ConnectionTimeoutError under load. Do the arithmetic on total connections: workers times pool times app servers, plus your Sidekiq or Solid Queue processes, has to stay under the PostgreSQL max_connections you actually have, which on a small RDS instance is often 100 or less. preload_app! loads the app once before forking so workers share memory through copy-on-write, but the parent's database sockets cannot be shared, so disconnect in before_fork and re-establish in on_worker_boot. Set worker_timeout so a hung worker is recycled, and remember that Rails 8 puts Thruster in front of Puma inside the container to handle TLS, compression and X-Sendfile, which keeps slow-client work out of your Ruby threads.
# config/puma.rb
workers Integer(ENV.fetch('WEB_CONCURRENCY', 4)) # roughly vCPU count
thread_count = Integer(ENV.fetch('RAILS_MAX_THREADS', 5))
threads thread_count, thread_count
preload_app!
worker_timeout 30
before_fork { ActiveRecord::Base.connection_pool.disconnect! }
on_worker_boot { ActiveRecord::Base.establish_connection }
# config/database.yml
# pool: <%= ENV.fetch('RAILS_MAX_THREADS') { 5 } %> # must be >= max threads
# 4 workers x 5 pool x 3 servers = 60 backends before jobs are counted
ActiveRecord::Base.connection_pool.stat
# => {size: 5, connections: 5, busy: 5, dead: 0, idle: 0, waiting: 3, checkout_timeout: 5.0}
Key Points
- Processes scale CPU, threads scale IO waiting, because of the GVL
- database.yml pool must be >= Puma max threads in every process
- workers x pool x servers must fit inside PostgreSQL max_connections
- preload_app! needs before_fork disconnect and on_worker_boot reconnect
Q34How do you route reads to a replica with connects_to, and what breaks when the replica lags?
IntermediateMultiple Databases
Answer
Declare both databases in database.yml under the environment, mark the replica with replica: true so db:migrate and schema dumps skip it, then call connects_to on your abstract base class with a writing and a reading role. After that ActiveRecord::Base.connected_to(role: :reading) routes everything inside the block to the replica, and any write attempted there raises ActiveRecord::ReadOnlyError. Rails also ships automatic switching: set config.active_record.database_selector with a delay and insert the resolver middleware, and after a session performs a write its reads go to the primary for that window, tracked through the session.
Understand that this is a heuristic, not a guarantee. It is per session, so a mobile client, a second browser tab or a webhook callback sees the replica immediately, and if lag exceeds the delay the user reads their own write as missing. The concrete production bugs are all read-your-write: a create that redirects to a show page which 404s, a job that writes and then calls a reporting helper wrapped in the reading role, and a counter that briefly reads low.
Two mechanical details interviewers probe: connected_to is thread local so a Thread.new inside the block does not inherit it, and reads inside a transaction always go to the writer because the transaction is open on that connection. The same machinery supports horizontal sharding through connects_to shards: and connected_to(shard:), and each database can own its own migration directory via migrations_paths, which is how Rails 8 keeps Solid Queue, Solid Cache and Solid Cable in separate databases with bin/rails db:migrate:queue.
# config/database.yml (production)
# primary:
# database: app_production
# primary_replica:
# database: app_production
# host: replica.internal
# replica: true # skipped by migrations and schema dumps
class ApplicationRecord < ActiveRecord::Base
primary_abstract_class
connects_to database: { writing: :primary, reading: :primary_replica }
end
# config/environments/production.rb
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver =
ActiveRecord::Middleware::DatabaseSelector::Resolver
ActiveRecord::Base.connected_to(role: :reading) do
MonthlyReport.build # any write here raises ActiveRecord::ReadOnlyError
end
# Per-database migrations (how the Solid trio is wired)
# $ bin/rails db:migrate:queue
Q35How do enum, the Attribute API and store_accessor change what a column means?
IntermediateData Modelling
Answer
enum maps a column to symbolic states and generates predicates, bang setters and scopes, so order.paid?, order.paid! and Order.paid all appear from one declaration. Rails 7 introduced the keyword form, enum :status, { pending: 0, paid: 1 }. Two traps come up constantly.
First, never use the array form: it binds meaning to array position, so a teammate who inserts a value in the middle silently reinterprets every row already stored. Always write an explicit hash, and treat the integers as an append-only contract. Second, the generated methods collide with existing ones: a value named new, valid, save or a name matching another enum raises a clear error at boot about a conflicting instance method, which is why prefix: true or suffix: true is the safer default on any model with more than one enum.
Rails 7.1 added validate: true so an unknown value produces a validation error instead of raising ArgumentError on assignment, which matters when the value comes from a CSV import or an API payload. The Attribute API is the general version of the same idea: attribute :price, :decimal declares a type for a real or virtual column, and a custom ActiveModel::Type::Value subclass lets you cast a Money object, a GSTIN or a comma-formatted amount consistently in forms, queries and serialization. store_accessor exposes keys inside a JSONB column as ordinary attributes, which is right for sparse per-record settings that do not deserve migrations. The cost is that JSONB gives you no NOT NULL, no foreign keys and no defaults, values come back as strings unless you also declare an attribute type, and querying needs a GIN index with the containment operator to avoid a sequential scan.
class Order < ApplicationRecord
enum :status, { pending: 0, paid: 1, refunded: 2 },
prefix: true, validate: true
attribute :discount_ratio, :decimal, default: 0 # virtual, typed
store_accessor :settings, :invoice_locale, :gst_treatment
attribute :invoice_locale, :string, default: 'en-IN'
end
Order.status_paid # scope
order.status_refunded? # predicate
order.status_paid! # assign + save
# BUG: position defines meaning, inserting a value rewrites history
# enum :status, [:pending, :paid, :refunded]
add_index :orders, :settings, using: :gin
Order.where('settings @> ?', { gst_treatment: 'reverse_charge' }.to_json)
Key Points
- Always the hash form; the array form ties meaning to position
- prefix/suffix avoid method collisions the boot check will otherwise reject
- validate: true turns a bad enum value into a validation error, not ArgumentError
- store_accessor needs a GIN index and an attribute type to behave predictably
Q36How does the built-in rate_limit controller macro work, and where is it not enough?
IntermediateSecurity
Answer
Rails 7.2 added rate_limit as a controller class macro, and it is a before_action underneath. It increments a counter in Rails.cache keyed by the controller, the action and a discriminator, and when the count crosses the limit inside the window it runs the with: handler, which defaults to returning 429 Too Many Requests. The discriminator defaults to request.remote_ip and you override it with by:.
Two implementation facts decide whether it actually protects anything. First, the counter lives in your cache store, so with :memory_store every Puma worker keeps its own count and the real limit becomes workers times the configured value. You need a shared store, Solid Cache or Redis, for the number to mean what you wrote.
Second, it is a fixed window, not a sliding window or a token bucket, so a caller can send the full quota at the end of one window and again at the start of the next, giving double the burst you intended. The India-specific trap is the discriminator. Behind an ALB or Cloudflare, ActionDispatch::RemoteIp derives remote_ip from X-Forwarded-For, and if trusted_proxies is not configured you can end up throttling the proxy address and locking out every user at once.
On top of that, large numbers of Indian mobile users sit behind carrier-grade NAT and share an IP, so an IP limit on an OTP endpoint punishes legitimate traffic. Discriminate by phone number, account id or API key instead. For genuine abuse, keep Rack::Attack or a WAF in front, because a request rejected inside the controller has already consumed a Puma thread, the middleware stack and a cache round trip.
class SessionsController < ApplicationController
rate_limit to: 5, within: 1.minute, only: :create,
by: -> { params.dig(:session, :phone).to_s },
with: -> { render json: { error: 'too_many_attempts' },
status: :too_many_requests }
end
# A per-worker cache makes the limit meaningless
config.cache_store = :solid_cache_store # not :memory_store
# Or every caller looks like the load balancer
config.action_dispatch.trusted_proxies = [IPAddr.new('10.0.0.0/8')]
# Cheaper rejection: refuse before Rails burns a thread
Rack::Attack.throttle('otp/phone', limit: 5, period: 60) do |req|
req.params['phone'] if req.post? && req.path == '/auth/otp'
end
Q37How does the GVL shape Rails concurrency, and what does YJIT actually change?
AdvancedConcurrency
Answer
CRuby holds a global VM lock, so exactly one thread per process runs Ruby bytecode at a time. Threads still buy real throughput because the GVL is released around blocking IO: a PostgreSQL query, an HTTP call to Razorpay, a file read. While one thread waits on a socket another runs Ruby.
That single fact explains most Rails capacity planning. An IO-heavy action scales well with five threads per worker, a CPU-heavy action (serializing a huge JSON payload, resizing an image in-process, a big Ruby loop) does not scale with threads at all, and worse, it stalls every other thread in that worker, which is the usual cause of a healthy p50 with an awful p99. So scale CPU with processes and IO with threads.
The GVL also does not make your code thread safe: a class-level memoized client is shared by every thread in the worker, so lazy initialisation races and mutable shared state corrupts. Use a per-thread instance, a real connection pool, or build the object per request. You can measure contention rather than guess: the gvltools gem exposes stall time using the Ruby 3.2 GVL instrumentation API, and a high stall number tells you threads are queueing for CPU rather than waiting on IO.
YJIT is a just-in-time compiler for hot Ruby, enabled by default in the Dockerfile Rails 8 generates. On typical Rails workloads it is worth a meaningful throughput gain in exchange for extra memory for the compiled code region, tunable with --yjit-exec-mem-size. It does not remove the GVL and does not make CPU-bound actions parallel.
Ractors remain experimental and no significant part of Rails is Ractor safe. Fibers are the real alternative for very high IO concurrency, and Rails supports config.active_support.isolation_level = :fiber for servers like Falcon.
# CPU bound: 5 threads in one worker are still about one core
# IO bound: 5 threads overlap 5 database round trips
# Thread-unsafe: shared, lazily built, racy
def self.gateway
@gateway ||= Gateway::Client.new
end
# Safe: one client per thread
def self.gateway
Thread.current[:gateway] ||= Gateway::Client.new
end
# Measure GVL stalls instead of guessing (gvltools gem)
GVLTools::LocalTimer.enable
# ... run the request ...
GVLTools::LocalTimer.monotonic_time # nanoseconds spent waiting for the GVL
# YJIT status inside the app
RubyVM::YJIT.enabled? # => true
RubyVM::YJIT.runtime_stats[:ratio_in_yjit]
# Fiber-per-request servers
config.active_support.isolation_level = :fiber
Key Points
- One thread per process runs Ruby; the GVL is released around IO
- CPU-bound work in one thread degrades every other thread in that worker
- gvltools measures stall time so you can prove threads are CPU starved
- YJIT trades memory for throughput and does not remove the GVL
Q38A Puma worker grows from 300 MB to 1.4 GB over a day. How do you diagnose and fix it?
AdvancedMemory
Answer
First separate a leak from bloat, because the fixes are different. A leak means objects stay reachable forever, typically a constant or class variable that accumulates: a memoised hash keyed by user id, a subscriber registered per request, an observer array. Bloat means Ruby freed the objects but the process never returned the pages to the operating system.
Bloat is far more common in Rails. Ruby allocates through malloc, and glibc creates up to eight arenas per core; fragmentation across those arenas makes RSS ratchet upward and never fall. Setting MALLOC_ARENA_MAX=2 helps, and switching the allocator to jemalloc helps more, which is precisely why the Dockerfile Rails 8 generates installs libjemalloc and preloads it.
Then find the allocation hotspots. derailed_benchmarks gives you perf:objects to attribute retained objects to a code path and perf:mem_over_time to show whether the process plateaus or climbs. rack-mini-profiler with memory_profiler shows allocations per request in staging. For a live worker, ObjectSpace.dump_all at two points and a diff of the heaps names the retaining class and file. The causes are usually mundane: a query that loads a whole table instead of using find_each, a CSV or PDF assembled in memory rather than streamed, a Sidekiq job handed a very large payload, image processing shelling out through MiniMagick instead of vips, and an unbounded cache on a constant. Fix those before touching GC parameters, which rarely pay off in 2026. puma_worker_killer and Puma's own worker recycling are band-aids that keep production alive while you do the real work, not a solution.
# Dockerfile generated by Rails 8
# RUN apt-get install -y --no-install-recommends libjemalloc2
# ENV LD_PRELOAD="libjemalloc.so.2" MALLOC_ARENA_MAX=2
# Attribute retained objects to a code path
# $ bundle exec derailed exec perf:objects
# $ bundle exec derailed exec perf:mem_over_time
# Heap snapshot from a live worker, then diff the two dumps
ObjectSpace.trace_object_allocations_start
File.open('/tmp/heap-1.json', 'w') { |f| ObjectSpace.dump_all(output: f) }
# Real leak: unbounded, lives as long as the process
RATES = {}
def rate_for(city) = RATES[city] ||= expensive_lookup(city)
# Real bloat: whole table instantiated, then a giant string built in memory
Order.all.map(&:to_csv_row).join
# Stream instead
Order.find_each { |o| response.stream.write(o.to_csv_row) }
Q39How does Solid Queue claim and run a job, and when would you still pick Sidekiq?
AdvancedBackground Jobs
Answer
bin/jobs starts a supervisor that forks three kinds of children: workers that run jobs, dispatchers that promote scheduled jobs when their time arrives, and a scheduler for recurring tasks defined in config/recurring.yml. An enqueue inserts into solid_queue_jobs and, if the job is ready now, into solid_queue_ready_executions. A worker polls with SELECT ...
FOR UPDATE SKIP LOCKED, which is the primitive that makes the whole design work: many pollers grab disjoint rows without blocking each other, so you can run several worker processes against the same table. Claimed rows move into solid_queue_claimed_executions stamped with the process id, and each process heartbeats into solid_queue_processes, so when a container is killed the supervisor notices the stale heartbeat and releases that process's claims for someone else to pick up. Concurrency controls are implemented as rows in solid_queue_semaphores, so limits_concurrency gives you a per-key serialisation that Sidekiq only offers in its paid tier.
The cost is polling: each worker queries every polling_interval, so ten worker processes are a constant baseline of queries, which is why Rails 8 puts the queue in its own database rather than the primary. Solid Queue wins when you want one less piece of infrastructure, when you want jobs inspectable in SQL and in the Mission Control Jobs UI, and above all because the enqueue is transactional: the job row commits with your data, so the after_save race that plagues Sidekiq simply cannot happen. Sidekiq still wins at very high throughput, where a Redis pop beats a database poll on both latency and load, and where you depend on its mature plugin ecosystem.
# config/queue.yml
# production:
# dispatchers:
# - polling_interval: 1
# batch_size: 500
# workers:
# - queues: [payments, default]
# threads: 5
# processes: 2
# polling_interval: 0.1
class SyncInventoryJob < ApplicationJob
limits_concurrency to: 1, key: ->(warehouse) { warehouse.id }, duration: 5.minutes
def perform(warehouse) = warehouse.sync!
end
# The claim, one statement per poll:
# SELECT job_id FROM solid_queue_ready_executions
# WHERE queue_name IN ('payments','default')
# ORDER BY priority, job_id LIMIT 5 FOR UPDATE SKIP LOCKED;
# Transactional enqueue: no after_save race at all
ApplicationRecord.transaction do
order.save!
SyncInventoryJob.perform_later(order.warehouse)
end
Key Points
- FOR UPDATE SKIP LOCKED lets many pollers claim disjoint jobs
- Heartbeats in solid_queue_processes recover claims from killed containers
- limits_concurrency uses semaphore rows, free where Sidekiq charges
- Polling is the running cost; Rails 8 isolates the queue in its own database
Q40Production is throwing ActiveRecord::ConnectionTimeoutError under load. Walk through the diagnosis.
AdvancedDatabase
Answer
The message reads could not obtain a connection from the pool within 5.000 seconds, all pooled connections were in use. A connection is checked out lazily at the first query in a request and returned by ActionDispatch::Executor when the request ends, so exhaustion means one of three things: the pool is smaller than the concurrency, connections are held far longer than the query takes, or something leaked one. Check them in that order.
Sizing is arithmetic: pool must be at least Puma max threads in a web process and at least the concurrency setting in a Sidekiq or Solid Queue process, and connection_pool.stat gives you size, busy and waiting live. Long holds are usually an external HTTP call inside a transaction or a query with no statement_timeout, so a gateway that gets slow silently converts into pool starvation across the fleet. Set statement_timeout and lock_timeout in database.yml so slowness fails instead of queueing.
Leaks are almost always a raw Thread.new inside a request or a job: that thread checks out its own connection and nothing ever returns it, because the executor only cleans up the thread it wrapped. Wrap the work in Rails.application.executor.wrap or use connection_pool.with_connection. Once sizing is honest you hit the server-side ceiling: app servers times workers times pool can exceed PostgreSQL max_connections, and every backend costs memory on the database. That is when PgBouncer in transaction pooling mode belongs in the path, with the caveat that it breaks Rails prepared statements unless you configure it accordingly, showing up as PG::DuplicatePstatement complaining that prepared statement a1 already exists, and it also breaks session-scoped state such as advisory locks held across statements.
# ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the
# pool within 5.000 seconds (waited 5.002 seconds); all pooled connections were in use
ActiveRecord::Base.connection_pool.stat
# => {size: 5, connections: 5, busy: 5, dead: 0, idle: 0, waiting: 7, checkout_timeout: 5.0}
# Leak: this thread checks out a connection nothing gives back
Thread.new { Report.generate! }
# Fix: let the executor manage checkout and checkin
Thread.new { Rails.application.executor.wrap { Report.generate! } }
ActiveRecord::Base.connection_pool.with_connection { |c| c.execute('VACUUM ANALYZE orders') }
# Fail fast instead of queueing (config/database.yml)
# variables:
# statement_timeout: 15000
# lock_timeout: 5000
# Behind PgBouncer transaction pooling
# ActiveRecord::StatementInvalid: PG::DuplicatePstatement:
# ERROR: prepared statement "a1" already exists
# prepared_statements: false
# advisory_locks: false
Key Points
- Executor returns the connection at end of request; a raw Thread bypasses it
- pool >= Puma threads and >= job worker concurrency, per process
- statement_timeout converts a slow dependency into an error, not starvation
- PgBouncer transaction pooling needs prepared_statements: false
Q41How would you implement multi-tenancy in Rails, and what leaks between tenants?
AdvancedArchitecture
Answer
Three strategies, and the interview is about the trade-off, not the gem. Row-level tenancy puts an account_id on every table and scopes every query. It is the cheapest to operate: one database, one migration run, one connection pool, and it scales to thousands of tenants.
The risk is that a single forgotten where clause leaks data across customers. Schema-per-tenant on PostgreSQL switches search_path per request, which gives clean isolation and per-tenant restore, but migrations run once per schema, the catalog gets unpleasant past a few thousand schemas, and search_path is session state so PgBouncer transaction pooling breaks it. Database-per-tenant is the strongest isolation and the answer BFSI and enterprise buyers in India often insist on; Rails supports it directly through connects_to shards and connected_to(shard:).
For row-level, the implementation that actually holds up combines ActiveSupport::CurrentAttributes to carry the tenant with PostgreSQL row level security so the database refuses cross-tenant rows even when application code forgets. Set the tenant as a session variable on checkout and write a policy against it. The leak vectors interviewers probe are rarely the main query.
Cache keys without the tenant id serve account A's dashboard to account B. Active Job arguments carry a record id but not the tenant, so the worker runs with Current unset or stale. GlobalID deserialization does a bare find that bypasses your scope.
Search index documents, exports, mailer previews and any Thread.new all sit outside the request lifecycle that resets Current. The executor resets CurrentAttributes between requests; nothing resets it inside a thread you spawned yourself.
class Current < ActiveSupport::CurrentAttributes
attribute :account
end
class ApplicationController < ActionController::Base
around_action do |_c, action|
Current.set(account: current_account) do
ActiveRecord::Base.connection.execute(
"SET LOCAL app.account_id = #{current_account.id.to_i}")
action.call
end
end
end
-- Database enforces it even when the scope is forgotten
-- ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- CREATE POLICY tenant_isolation ON orders USING
-- (account_id = current_setting('app.account_id')::bigint);
# Tenant must be in the cache key, or you serve the wrong dashboard
Rails.cache.fetch([Current.account, 'dashboard', 'v2'], expires_in: 5.minutes) { ... }
# Jobs do not inherit Current: pass the tenant explicitly
class ExportJob < ApplicationJob
def perform(account_id, scope_id)
Current.set(account: Account.find(account_id)) { build(scope_id) }
end
end
Key Points
- Row-level scales cheapest, schema-per-tenant conflicts with PgBouncer pooling
- Row level security makes the database the last line of defence
- Cache keys, job arguments and GlobalID lookups are the real leak paths
- CurrentAttributes resets per request, never inside a spawned thread
Q42How do you take a Rails 6.1 monolith to Rails 8 without a long-lived upgrade branch?
AdvancedUpgrades
Answer
Never jump versions. Go 6.1 to 7.0 to 7.1 to 7.2 to 8.0, shipping each step. The mechanism that makes each hop safe is the pair of config.load_defaults and config/initializers/new_framework_defaults_X_Y.rb.
Bump the gem but leave load_defaults at the old value, so behaviour is unchanged, then uncomment one new default per deploy and watch the error rate. Only when the file is empty do you raise load_defaults and delete it. Before any bump, clear deprecations: set config.active_support.deprecation = :raise in the test environment so a warning fails CI, then ship a deprecation-free codebase on the old version.
To avoid the branch that rots for four months, dual boot. The bootboot gem lets one repository resolve two lockfiles, Gemfile.lock and Gemfile_next.lock, so CI runs the suite on both the current and the next Rails from the same commit and every merge stays compatible with both. Concrete blockers on that path: Rails 7.0 removed the classic autoloader, so Zeitwerk is mandatory and bin/rails zeitwerk:check must pass; Webpacker is retired, so you migrate to importmaps, jsbundling or Propshaft; Rails 7.2 needs Ruby 3.1 or newer and Rails 8.0 needs Ruby 3.2 or newer, so the Ruby upgrade is often the real project; and bin/rails app:update rewrites config files, so review that diff line by line rather than accepting it.
In practice the hardest part is not Rails at all, it is unmaintained gems. Audit the Gemfile early, because one abandoned dependency can gate the entire upgrade.
# Step 1: gem bumped, behaviour unchanged
# config/application.rb
config.load_defaults 7.1 # not 8.0 yet
# Step 2: flip one new default per deploy
# config/initializers/new_framework_defaults_7_2.rb
# Rails.application.config.active_record.postgresql_adapter_decode_dates = true
# Fail CI on any new deprecation
# config/environments/test.rb
config.active_support.deprecation = :raise
# Dual boot: one branch, two lockfiles (bootboot)
# $ DEPENDENCIES_NEXT=1 bundle install
# $ DEPENDENCIES_NEXT=1 bin/rails test
# Mandatory before Rails 7.0
# $ bin/rails zeitwerk:check
# Review, do not blindly accept
# $ bin/rails app:update
Q43How does Active Record Encryption work, and how do you apply it to PII under India's DPDP Act?
AdvancedSecurity
Answer
Rails 7 added declarative encryption: encrypts :pan_number on the model and the value is encrypted in Ruby with AES-256-GCM before the INSERT and decrypted on read. Keys live in credentials under active_record_encryption (primary_key, deterministic_key, key_derivation_salt), generated by bin/rails db:encryption:init. The stored value is ciphertext plus a JSON header, noticeably larger than the plaintext, so a tight varchar column starts raising value too long errors and usually needs widening to text first.
The important decision is deterministic or not. The default is non-deterministic: a random initialisation vector means the same input produces different ciphertext every time, which is the safest option but makes the column unqueryable and unindexable. Passing deterministic: true derives a fixed IV so equal plaintexts produce equal ciphertext, which lets you run where(email: value) and keep a unique index, at the cost of leaking equality to anyone holding a database dump.
Use deterministic only for lookup keys, never for a field like a bank account number. Migrating live data is a two-phase job: set support_unencrypted_data = true so reads tolerate plaintext, backfill in batches, then turn it off. Key rotation works by listing previous keys, and decryption tries them in order.
Gotchas worth naming: update_all and raw SQL bypass encryption entirely, so a backfill written as SQL silently writes plaintext, and encryption does nothing about the plaintext still flowing through logs, params and your error tracker unless you extend filter_parameters. For DPDP obligations, encryption is one control. You still need a deletion path for erasure requests, retention limits, and an audit trail of who read the data, and if the master key sits in the same place as the backup, the compliance value is thin.
class Customer < ApplicationRecord
encrypts :pan_number # random IV: safest, unqueryable
encrypts :email, deterministic: true # queryable + indexable
encrypts :notes, downcase: false
end
# bin/rails db:encryption:init -> paste into credentials
# active_record_encryption:
# primary_key: ...
# deterministic_key: ...
# key_derivation_salt: ...
Customer.where(email: 'ravi@example.in') # works only because deterministic
# Phase 1 of a live migration
config.active_record.encryption.support_unencrypted_data = true
Customer.find_each { |c| c.encrypt } # NOT update_all, which bypasses encryption
# Plaintext still leaks here unless you filter it
config.filter_parameters += [:pan_number, :aadhaar, :email]
Key Points
- AES-256-GCM applied in Ruby; ciphertext plus header needs a wider column
- Deterministic enables queries and indexes but leaks equality
- update_all and raw SQL bypass encryption entirely
- filter_parameters, deletion paths and key custody are the rest of the job
Q44What actually happens during kamal deploy, and how does the app stay up through it?
AdvancedDeployment
Answer
Kamal is a deploy tool built on SSH and Docker with no orchestrator underneath, and Rails 8 generates its config by default. kamal setup installs Docker on each host and boots kamal-proxy, which replaced Traefik in Kamal 2 and handles request routing plus automatic Let's Encrypt certificates. kamal deploy then builds the image (locally or on a remote builder via buildx, with layer caching), pushes it to your registry, pulls it on every host in parallel, starts the new container tagged with the git SHA, and polls the health endpoint until it passes. That endpoint is /up, the rails/health#show route Rails generates. Only after the healthcheck succeeds does Kamal tell kamal-proxy to switch traffic to the new container, and the proxy holds incoming requests briefly during the swap rather than dropping them, which is where the zero downtime comes from.
The old container is stopped afterwards, which is also what makes kamal rollback fast: the previous image is still on the host, so rollback reboots a tag rather than rebuilding. config/deploy.yml declares servers by role, so web and job hosts get different commands from the same image. Secrets come from .kamal/secrets, resolved from environment variables or a secret manager at deploy time and never committed. Two practical traps.
First, builder arch: building on an Apple Silicon Mac and deploying to amd64 hosts without setting it produces an exec format error on boot. Second, because old and new containers overlap during the swap, every migration must be backwards compatible with the code still running, which is exactly why the two-deploy column drop and ignored_columns exist.
# config/deploy.yml
service: billing
image: acme/billing
servers:
web:
- 10.0.1.11
job:
hosts: [10.0.1.12]
cmd: bin/jobs # Solid Queue supervisor
proxy:
ssl: true
host: app.example.in
healthcheck:
path: /up
builder:
arch: amd64 # Apple Silicon -> amd64 hosts
env:
clear:
RAILS_MAX_THREADS: 5
secret:
- RAILS_MASTER_KEY
- DATABASE_URL
# $ kamal deploy
# $ kamal app exec --interactive "bin/rails console"
# $ kamal app logs -f -r web
# $ kamal rollback 8f2c1ab # reboots the previous image tag
Key Points
- kamal-proxy switches traffic only after /up passes, and buffers during the swap
- Roles run the same image with different commands (web vs bin/jobs)
- Rollback reboots a previous tag already on the host, so it is seconds
- Old and new containers overlap, so migrations must be backwards compatible
Q45How does Action Cable behave at scale, and what does Solid Cable change about it?
AdvancedAction Cable
Answer
Action Cable mounts a WebSocket endpoint at /cable inside your Rails process. When a browser connects, ApplicationCable::Connection#connect runs, identified_by :current_user pins an identifier, and reject_unauthorized_connection closes anything it cannot authenticate from cookies or a token. The client then subscribes to channels, and stream_from or stream_for registers that subscription against a broadcasting name in the pubsub adapter, so a broadcast anywhere in the fleet reaches every process holding a matching subscriber.
The scaling reality is resource cost per socket. Each open connection is held by the server process, dispatch runs on the Action Cable worker pool, and any query in a channel action or a subscription callback takes a database connection from the same pool your web requests use. Thousands of mostly idle sockets become a capacity problem well before message volume does, which is why high-connection apps run cable as a separate deployment with its own pool, or put AnyCable in front so Go holds the sockets and Ruby only handles the RPC.
Adapters: async is development only and in process, redis fans out through Redis pub/sub, and Solid Cable is the Rails 8 default, which writes messages to a solid_cable_messages table and has subscribers poll it. That removes Redis from your stack at the cost of polling latency measured in tens of milliseconds, which is invisible for a notification badge and wrong for a trading feed. Solid Cable also keeps a short retention window rather than replaying history.
The security bug to name is an unauthorized stream name: stream_from with an interpolated params value lets any authenticated user subscribe to any other tenant's channel. Always derive the stream from a record you have already authorized.
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = User.find_by(id: cookies.signed[:user_id]) ||
reject_unauthorized_connection
end
end
end
class RoomChannel < ApplicationCable::Channel
def subscribed
# BUG: any user can subscribe to any room
# stream_from "room_#{params[:id]}"
stream_for current_user.rooms.find(params[:id]) # authorized first
end
end
# config/cable.yml (Rails 8 default)
# production:
# adapter: solid_cable
# polling_interval: 0.1.seconds
# message_retention: 1.day
# Render off the request thread
after_update_commit -> { broadcast_replace_later_to account }
Frequently Asked Questions
What does a Ruby on Rails developer earn in India in 2026?
The working band is roughly ₹6-22 LPA, and the spread is wider than for Java or Node because the Rails market is smaller and more concentrated. Freshers with a real deployed project typically start at ₹4-7 LPA, and consultancies that run Rails as their core practice, such as BigBinary, Josh Software and Kiprosh, are the most reliable entry point. At two to four years, ₹9-16 LPA is common once you can show ActiveRecord performance work and production ownership rather than just CRUD. Senior engineers at product companies like Freshworks and BrowserStack, or at remote-first foreign employers hiring from India, sit in the ₹22-40 LPA range, and remote roles paid in USD go higher still. The scarcity works in your favour at the senior end: fewer candidates genuinely understand connection pooling, locking and zero-downtime migrations, so companies with a large Rails codebase pay to keep the ones who do.
How long does it take to prepare for a Rails interview?
If you already ship Rails at work, two to three weeks of focused revision is usually enough. Spend the first week on ActiveRecord internals, because that is where most of the interview lives: preload versus eager_load, transactions and savepoints, optimistic and pessimistic locking, and what find_each actually does. Week two should cover production topics that separate mid from senior, meaning zero-downtime migrations, idempotent background jobs, caching keys and Puma plus pool sizing. Week three is practice: build one small app end to end with Hotwire, Solid Queue and a real deploy, and be able to walk through your own code. If you are coming from another framework, budget eight to twelve weeks, because Rails convention over configuration only becomes an advantage once the conventions are second nature, and interviewers can tell within two questions whether you have internalised them or memorised them.
What is expected from a fresher versus an experienced Rails candidate?
A fresher is judged on fundamentals and evidence. You need MVC, the request lifecycle, associations, validations, migrations, strong parameters and basic ActiveRecord querying, plus one deployed project with a real database that you can explain honestly. Nobody expects you to have tuned a connection pool. What does get checked is whether you can read an error message and reason about it. At two to five years the questions shift entirely to production behaviour: why a query got slow, how you found an N+1, what happened the last time a deploy caused an incident, how you made a job safe to retry. Beyond five years the interview becomes design and judgement, meaning where business logic belongs, how you would upgrade a legacy Rails 5 codebase, when a background job should become a separate service, and how you would run a migration on a table with 200 million rows. Concrete war stories beat theory at every level above fresher.
Is Ruby on Rails still worth learning in 2026?
Yes, with clear eyes about the market. There are fewer Rails openings in India than Node, Java or Python roles, so if you want maximum job volume in Bengaluru or Hyderabad this is not the highest-count choice. What Rails offers instead is leverage and less competition. Rails 8 lets one engineer run background jobs, caching, WebSockets and deploys with no Redis and no Kubernetes, which is why funded startups with small teams keep picking it, and why GitHub, Shopify and Zendesk still run enormous Rails codebases. The candidate pool at the senior end is thin, so an engineer who genuinely understands ActiveRecord under load is unusually hard to replace. It is also a strong second framework: the concepts you learn about ORM behaviour, transactions and caching transfer directly to Django, Laravel and NestJS. Treat it as a career multiplier rather than a first-job lottery ticket.
Rails versus Django versus NestJS: which should I pick?
They solve the same problem with different defaults. Rails is the most opinionated and the most batteries-included: ActiveRecord, Active Job, Active Storage, Hotwire and now Solid Queue and Solid Cache all ship together, so a small team goes from empty repo to production fastest. Django gives you a similar bundle plus a genuinely useful admin interface out of the box, and it wins by default when the same team also does data science or machine learning, because the Python ecosystem is right there. NestJS is the choice when the organisation is already TypeScript end to end and wants one language across frontend and backend, and it gives you stronger typing and a more explicit dependency injection structure at the cost of more wiring per feature. In the Indian job market, NestJS and Node have the most openings, Django is strongest where Python already exists, and Rails pays best per unit of competition at the senior end. Pick on the team and the codebase you are joining, not the benchmark charts.
Do I need Hotwire, or should I learn React alongside Rails?
For Rails interviews in 2026, know Hotwire. It is the default front end in Rails 8, and being unable to explain the difference between a Turbo Frame and a Turbo Stream reads as someone who has not touched Rails recently. It is also a small surface area, so a weekend of building inline edit forms and live updating lists covers most of what gets asked. React is a separate answer to a separate question. Plenty of Indian teams run Rails as a JSON API with a React or Next.js frontend, and those job posts say so clearly, so read the description. If you want to maximise options, learn Hotwire properly and keep enough React to be useful in a code review. What actually gets probed either way is the boundary: how you version a JSON API, how you paginate, how you serialize, and how the frontend revalidates cheaply on a slow mobile connection.
Introduction
Rails in 2026 is a different framework from the one that powered the 2013 startup boom. Rails 8 shipped the Solid trio (Solid Queue, Solid Cache, Solid Cable), which lets a production app run background jobs, caching and WebSockets on the primary database with no Redis at all. Propshaft replaced Sprockets as the default asset pipeline, importmaps removed the Node build step for most apps, Kamal plus Thruster made container deploys a one-command affair, and YJIT is on by default on modern Ruby. The result is a stack where a small team ships and operates a real product without an infrastructure specialist.
Interviews reflect that shift. Nobody spends thirty minutes asking you to define MVC anymore. What gets probed is ActiveRecord behaviour under load: when preload beats eager_load, why after_save enqueued a job that read a stale row, how lock_version prevents a double refund, why Puma threads and the database pool have to be sized together, and how you add an index to a 200 million row table without taking the site down. Indian employers hiring Rails include Freshworks and BrowserStack, global product teams like GitHub, Shopify and Zendesk, plus specialist consultancies such as BigBinary, Josh Software and Kiprosh that run Rails as their core practice.
This page covers 45 Ruby on Rails interview questions asked in 2026, ordered from fundamentals to the senior topics that actually decide offers. Every answer explains how the framework behaves at runtime, not just what the documentation says, and flags the production failure mode that follows when you get it wrong. Most questions carry a runnable Ruby snippet. Work through the basic block to tighten your ActiveRecord and request-lifecycle fundamentals, then spend real time on the intermediate and advanced sections covering locking, zero-downtime migrations, the GVL, Solid Queue internals, memory bloat and multi-tenancy.
Ready to practice Ruby on Rails interviews?
Don't just read, practice these Ruby on Rails questions live with an AI interviewer that asks follow-ups and scores your answers.