These 27 Ruby on Rails interview questions help you find developers who can own a Rails application in production, from Ruby itself through Active Record performance to upgrades and deployment on Rails 8. Rails makes the first version of a feature easy, so the questions focus on what happens later: callbacks that surprise people, queries that slow down as tables grow, and migrations that lock production. At Ryz, Rails candidates are sourced by recruiters who check the apps they actually maintained, then complete structured NTRVSTA AI interviews on topics like these. Recruiters review every candidate before and after, AI scores are advisory, and the hiring decisions stay with people.
Rails experience varies widely, from developers who have only built greenfield apps to engineers who have kept a ten-year-old monolith healthy through five major upgrades. Ask about the size and age of the apps they worked on, then choose questions to match. For senior roles, spend most of the time in the Active Record and architecture sections and ask for real incidents.
Good answers mention trade-offs and data size. "It depends on how many rows" is often the beginning of a strong answer, not an evasion.
A block is syntax attached to a method call. Procs and lambdas are objects. Lambdas check argument count strictly and return exits only the lambda. Procs are lenient about arguments and return exits the enclosing method, which raises LocalJumpError if that method has already returned.
What a strong answer shows: Precise knowledge of return and arity behavior, which matters when passing callables around.
include, extend and prepend differ?include adds a module's methods as instance methods, inserted after the class in the lookup chain. extend adds them to a single object, often the class itself as class methods. prepend inserts the module before the class, so its methods run first and can call super, a clean way to wrap behavior.
module Audited
def save(...)
result = super(...)
AuditLog.record(self) if result
result
end
end
class Invoice < ApplicationRecord
prepend Audited
end
What a strong answer shows: They can explain ancestors and method lookup instead of reaching for monkey patches.
# frozen_string_literal: true?It makes string literals in that file immutable, so the same literal is not allocated again on each call, which reduces garbage. Mutating such a string raises a FrozenError. Ruby 3.4 made other string literals "chilled": mutating one emits a deprecation warning when those warnings are enabled, a step toward frozen literals by default.
What a strong answer shows: Awareness of allocation cost and where the language is heading.
They stop mass assignment by requiring an explicit list of permitted attributes before params reach a model. Rails 8 added params.expect, which combines require and permit and returns a 400 instead of raising a 500 when someone sends a malformed structure.
def user_params
params.expect(user: [:name, :email, :time_zone])
end
What a strong answer shows: They know the security purpose and current API, and never permit role or admin fields from user input.
When they hold side effects such as sending emails, calling APIs or touching other models. They run on every save, including in tests, scripts and imports, and they make the order of operations hard to follow. Keep callbacks for data normalization on the model itself, and move side effects to explicit service objects or jobs. When a callback must trigger external work, use after_commit so it does not fire for a rolled-back transaction.
What a strong answer shows: Experience with callback-heavy apps and a clear rule for what belongs where.
schema.rb or structure.sql?schema.rb is database-agnostic and easy to review but cannot express everything. Once you use database features it does not capture, such as certain triggers, functions or some constraint types, switch to structure.sql. Either way, commit it and treat it as the source of truth for test databases.
What a strong answer shows: They use the database fully rather than limiting it to what the DSL covers.
validates :email, uniqueness: true runs a SELECT before the INSERT, so two concurrent requests can both pass. The real guarantee is a unique index in the database. Keep the validation for friendly error messages, add the index, and handle ActiveRecord::RecordNotUnique.
add_index :users, :email, unique: true
What a strong answer shows: They trust constraints over application-level checks for integrity.
Simple logic belongs on models. As workflows span several models and external services, plain Ruby objects such as service or command objects keep controllers thin and models focused. Concerns are useful for genuinely shared behavior but turn into hidden dependencies when used just to split a large model into files.
What a strong answer shows: Pragmatism, not dogma about "fat models" or service objects everywhere.
Nested fragment caches use cache keys built from each record's cache_key_with_version. When a child updates and uses touch: true on its parent association, the parent key changes and only the affected fragments re-render. It breaks when touches are missing, when templates depend on data not in the key such as the current user, or when touch chains cause write storms on hot parents. Rails 8's Solid Cache stores the cache in the database, which allows larger caches at lower cost than memory.
What a strong answer shows: They understand key-based expiry and its failure modes.
The job was pushed to the queue before the transaction committed, and a fast worker picked it up first, or the transaction rolled back. Enqueue after commit, either from an after_commit callback or with Active Job's enqueue_after_transaction_commit setting available in recent Rails versions; check how your app configures it. Solid Queue stores jobs in the database, and when it shares the app's database the enqueue can be part of the same transaction.
What a strong answer shows: They understand the race between transactions and queues.
Solid Queue is the Rails 8 default, uses the database with FOR UPDATE SKIP LOCKED, and removes Redis from the stack, with recurring jobs and concurrency controls built in. Sidekiq handles very high throughput with low latency and has a mature ecosystem and paid features. For most apps Solid Queue is enough; pick Sidekiq when job volume or latency requirements outgrow the database.
What a strong answer shows: A choice based on volume and operational cost.
Turbo Frames replace part of a page from a normal HTML response, good for inline editing and tabs. Turbo Streams apply targeted updates over HTTP responses or WebSockets for live lists and notifications. Stimulus adds small behaviors. A React or Vue front end makes sense for highly interactive interfaces like editors or complex dashboards where client-side state dominates.
What a strong answer shows: They match the tool to how interactive the UI really is.
Most coverage in model and request tests, a few system tests for critical flows. Use fixtures or lean factories without deep association chains, run tests in parallel, and avoid hitting external services with recorded responses or fakes. Track slow tests and flaky ones as bugs.
What a strong answer shows: They think about the shape of a suite, not just coverage percentage.
The generator creates readable session-based authentication code with has_secure_password that the team owns and can change. Devise covers more out of the box, such as confirmable accounts and lockouts, at the cost of more configuration and indirection. For a new app with simple needs the generator is a good start; OAuth providers or complex flows may still favor Devise or OmniAuth.
What a strong answer shows: Current Rails knowledge and a view on owning code versus depending on gems.
includes, preload and eager_load?preload runs a separate query per association. eager_load uses a single LEFT OUTER JOIN. includes picks one, switching to a join when you reference the association in conditions. To catch N+1s early, turn on strict loading in development and tests.
orders = Order.includes(:customer, line_items: :product)
.where(created_at: 30.days.ago..)
.strict_loading
What a strong answer shows: They choose the loading strategy deliberately and enforce it.
Check logs or APM for the slow queries, then run .explain on them. Common fixes: a composite index that matches the filter and sort, selecting only needed columns, a counter_cache instead of COUNT per row, and keyset pagination instead of large OFFSET values. Then confirm the change in production metrics.
What a strong answer shows: Query plans and indexing, not just eager loading.
Not with Model.all.each, which loads every record. If no callbacks or validations are needed, use in_batches.update_all(...), which skips callbacks and runs one UPDATE per batch. If model logic must run, use find_each inside a job, with throttling to protect replicas.
What a strong answer shows: They know which methods skip callbacks and plan for lock time and replication lag.
Read-modify-write without locking loses updates. Use a pessimistic row lock for short critical sections, or optimistic locking with a lock_version column when conflicts are rare.
wallet.with_lock do
raise InsufficientCredits if wallet.balance < amount
wallet.update!(balance: wallet.balance - amount)
wallet.ledger_entries.create!(amount: -amount)
end
An atomic SQL update with a condition, such as UPDATE ... SET balance = balance - ? WHERE balance >= ?, also works for simple cases.
What a strong answer shows: They recognize lost updates and know several correct fixes.
A normal CREATE INDEX blocks writes for the whole build. Build it concurrently, which cannot run inside a transaction.
class AddCustomerIdIndexToOrders < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :orders, :customer_id, algorithm: :concurrently
end
end
The strong_migrations gem catches unsafe operations like this, adding columns with volatile defaults, or renaming columns in use.
What a strong answer shows: Production database awareness and tools that prevent mistakes.
Look for objects held across requests, large result sets loaded into memory, and fragmentation, which running Ruby with jemalloc reduces. Check the thread and worker counts against the database pool: each process needs a pool at least as large as its thread count. Enable YJIT for CPU gains, but measure its memory overhead. Use derailed_benchmarks or memory profilers to find allocations per request.
What a strong answer shows: They tie memory to Ruby's allocator and to Puma configuration.
Use hash conditions and placeholders, as in where("total > ?", min), never string interpolation of params. For dynamic sort columns, allowlist them, because column names cannot be bound. Build reusable filters as scopes and combine them, and drop to Arel or SQL only when the query needs it.
What a strong answer shows: Safe composition, especially for sorting and filtering inputs.
One minor version at a time: 7.0, 7.1, 7.2, then 8.0. At each step fix deprecation warnings, update gems that block the upgrade and run the full suite. Use a dual-boot setup to run CI on both versions, then flip config.load_defaults settings gradually with the new_framework_defaults file. Upgrade Ruby separately so you know which change caused a problem.
What a strong answer shows: An incremental, low-risk process from someone who has done it.
Draw domain boundaries first and make them visible: namespaces, engines or Packwerk packages with declared dependencies. Enforce boundaries in CI, move cross-domain calls behind small public APIs, and give each area an owning team. Extract a service only when a domain truly needs separate scaling or release cycles.
What a strong answer shows: They prefer a modular monolith before distribution.
Kamal 2 deploys Docker containers to your own servers over SSH, with kamal-proxy handling zero-downtime switches and TLS. It suits teams who want simple infrastructure without Kubernetes. Thruster sits in front of Puma for compression and asset caching. Larger platforms may still choose Kubernetes or a PaaS for autoscaling. Whatever the platform, migrations must be backward compatible so old and new code run side by side.
What a strong answer shows: Current deployment options and a release process that tolerates mixed versions.
Configure multiple databases with connects_to and use automatic role switching, which sends GET requests to the replica after a delay since the last write. Use connected_to(role: :reading) for explicit reads in jobs. Watch replication lag, and keep read-after-write flows on the primary.
What a strong answer shows: Built-in multi-database support and awareness of stale reads.
Separate queues by priority and latency target, with dedicated workers so bulk imports cannot starve password-reset emails. Break large jobs into smaller idempotent ones, add concurrency limits for jobs that hit the same external API, and alert on queue latency rather than queue length.
What a strong answer shows: Queue design as capacity planning.
Compare APM traces before and after to see which endpoints and spans changed. Check for new N+1 queries, a dropped index, a cache key change that emptied the cache, or a gem upgrade. Roll back if users are affected, then reproduce with production-like data.
What a strong answer shows: Calm, evidence-based incident handling with rollback as a normal option.
after_save callbacks..explain.Share a Rails 8 app with Postgres and a seed script that creates a few hundred thousand orders. It has an orders dashboard, a checkout that deducts store credit, and a nightly job that recalculates loyalty tiers. Plant problems: N+1 queries on the dashboard, a missing composite index, a lost-update race in checkout and a job that loads every customer into memory. Give the candidate about three hours as a take-home, or 90 minutes pairing on the two worst issues.
.explain or a profiler before changing code?Ryz introduces senior Ruby on Rails developers who have been assessed on Active Record performance, safe migrations and upgrades. They are the top 1% of the candidates we interview, they work on your team and repos, and they keep hours within ±1h of US time zones. Learn how our vetting process works, or adapt our Ruby on Rails developer job description.
Enough to confirm they understand blocks, modules and object lookup, because Rails relies on them. Most of the interview should cover Rails and the database, where production problems come from.
Ask about the largest table they worked with, the hardest migration they ran and the last upgrade they led. Production engineers answer with specifics: row counts, lock times, what broke and how they rolled back.
If your app uses it, yes, and it is quick to learn for someone who knows Rails views. If your front end is React or Vue over a Rails API, test API design, serialization and authentication instead.
Questions we didn't answer? Email info@ryzlabs.com.