cswork / libraryOpen practice workspace

Articles

Ruby on Rails Interview Questions: Active Record, Transactions, and Background Jobs

Practice Ruby on Rails interview questions with N+1 query fixes, transaction examples, callback timing, and safe background job retry scenarios.

Ruby on Rails interview questions become easier when you follow what happens to one request. Which SQL statements run? Which changes commit together? When can a background worker safely read the result? These questions connect Active Record, transactions, callbacks, and Active Job to the problems you will actually debug.

This guide uses an order-processing application as a worked example. The examples are practice scenarios, not claims about a particular company's interview. They focus on reasoning you can explain aloud, then support with small tests and query logs.

Start with the examples below and change one assumption: add concurrent requests, a failed database write, or a job retry. Those changes reveal whether the design still works.

What should you know before a Rails interview?

Be ready to explain these distinctions in ordinary language:

  • A relation describes a database query; loading records executes work and creates Ruby objects.
  • A join can filter records without preloading the associations that a view later reads.
  • A model validation helps produce a useful error, while a database constraint protects data under concurrent writes.
  • A transaction groups database changes on a connection. It does not roll back an email or an external payment.
  • A callback after a save can still run before the surrounding transaction commits.
  • A background job must tolerate the delivery and retry behavior of its actual queue backend.

In an interview, state the Rails version, database, and queue assumptions when they change your answer. A useful response explains the behavior of a specific setup instead of assuming every Rails application uses identical defaults.

How do you find an N+1 query in Active Record?

Suppose a page displays 20 orders and the customer name for each order:

orders = Order.order(created_at: :desc).limit(20)
orders.each do |order|
  puts order.customer.name
end

The first query loads orders. Accessing each unloaded customer association can then issue another query. With this simple association and no effective caching, that is one query plus up to 20 more. The important clue is a repeated SQL shape whose parameter changes for every row.

Use logs to count queries while rendering the actual page, including the serializer or partial. Looking only at controller code misses lazy association reads farther down the call chain. Test more than one record: an N+1 problem often looks harmless with a single fixture.

orders = Order.preload(:customer)
              .order(created_at: :desc).limit(20)

For this example, preloading fetches the customer associations separately and avoids the per-order lookup. The practical claim to test is that the customer-query count stays bounded as the number of displayed orders grows. Do not promise that every association graph always uses exactly two queries; nesting and association types can add work.

Should you use includes, preload, eager_load, or joins?

Choose based on the result you need and the SQL you expect, then inspect the actual query.

MethodMain purposeInterview trap
preloadLoad associations through separate queriesIt is not a joined-table filtering strategy
eager_loadLoad associations through a left outer joinRepeated parent columns can increase result size
includesEager-load associations, with query strategy affected by useDo not assume it always means separate queries
joinsJoin tables for selection and filteringIt does not automatically mark associations loaded

A useful follow-up is: return customers with paid orders, but show all their orders. Filtering through a joined association and loading that same association can accidentally blur two requirements. Decide whether the displayed collection should contain only matching orders or every order belonging to a matching customer.

One approach is to select qualifying customer IDs and load customers with a separate preload. Test a customer who has one paid and one unpaid order. That tiny fixture catches the difference immediately. Also consider whether distinct is necessary when a join returns several matching child rows per customer.

Why is a uniqueness validation insufficient?

Imagine two requests create an account with the same email. Both ask whether that email already exists. Both see no record, and both try to insert. A validation query cannot prevent this race by itself.

Use a matching database unique constraint or index, and handle the resulting conflict in the application. Be precise about what “matching” means. If email comparison is case-insensitive in your product, a case-sensitive database index may protect a different invariant. Normalization, collation, and null handling belong in the answer.

Here is a useful test plan: start two attempts with the same normalized value, then verify that at most one row exists. Assert an understandable application outcome for the loser. A test that only calls valid? twice sequentially never exercises the concurrency problem.

For inventory, use the same reasoning. Reading stock as one and later saving zero is unsafe when two requests can read the same starting value. Consider a row lock or an atomic conditional update, and check the affected-row count. The protected invariant is that successful reservations never exceed available inventory.

What does an Active Record transaction actually guarantee?

Suppose checkout creates an order and reserves an inventory unit. If both writes use the same database connection and transaction, a failure can roll them back together.

Order.transaction do
  order = Order.create!(customer: customer, status: "pending")
  InventoryReservation.create!(order: order, product: product)
end

The bang methods matter here: a validation failure raises, allowing ordinary exception handling to abort the transaction. A non-bang save returning false can be ignored accidentally, leaving earlier writes eligible to commit. Always explain the error path as well as the successful path.

Keep the transaction short. Holding locks while waiting for a slow external service increases contention and makes timeout behavior harder to reason about. If an operation spans multiple databases or services, an ordinary Active Record transaction is not a distributed transaction across all of them.

Another subtlety is nested transaction syntax. A nested block does not necessarily create an independent database transaction. Rails documents savepoint behavior with requires_new; database support matters. Ask which changes should survive an inner failure before choosing a nesting strategy.

How are after_save and after_commit different?

after_save runs as part of the record's save lifecycle. The surrounding transaction may still roll back later. after_commit belongs to the successful transaction outcome, making it a better place for actions that require committed data.

Consider this timeline: save an order, send a confirmation, then fail while reserving stock. If the confirmation went out before commit, the customer received a message for an order that does not exist. Moving the action after commit removes that particular ordering error.

It does not make the database and an external service atomic. The process can stop after commit and before delivering the message. Conversely, a delivery may succeed while its acknowledgement is lost. These are two distinct failure windows, and a strong answer names both.

For a durable event, write an outbox record in the same transaction as the order. A separate worker reads pending outbox records and delivers them using a stable event identifier. This is a design pattern, not a built-in guarantee that any arbitrary callback provides. Delivery still needs retries, monitoring, and a strategy for duplicates.

Can a background job run before the transaction commits?

It can in configurations where enqueueing becomes visible to workers before the database transaction finishes. A fast worker might attempt to find the order while the creating transaction is still uncommitted.

Current Rails provides transaction-aware enqueueing behavior and configuration, but the exact guarantee depends on Rails, the adapter, and database placement. Check those settings instead of repeating the blanket claim that perform_later always waits for commit. Rails' Active Job guide specifically discusses transactional integrity and enqueue-after-commit configuration.

In an interview exercise, draw three moments: order insert, database commit, worker lookup. The lookup must happen after the committed record becomes visible to the worker's connection. Then ask a second question: what happens if the application stops between the commit and the enqueue? Ordering and reliable eventual delivery require different reasoning.

Pass a stable record identifier or supported serialized reference to the job. Decide what the job should do if the record was legitimately deleted before execution. Retrying a permanently missing record forever is usually unhelpful; silently ignoring every lookup failure is equally hard to operate.

How do you make a retried job safe?

Suppose ChargeOrderJob calls a payment provider, then marks the order paid. The provider charges successfully, but the worker stops before the database update. A retry must not create a second charge.

Use a stable idempotency key for that business operation if the provider supports one. Reuse it across retries; generating a new random key on every attempt defeats the protection. Record provider references so reconciliation can distinguish an unattempted payment from a completed payment whose local update failed.

Failure pointWhat might already exist?Recovery question
Before provider requestPending order onlyIs retry still allowed?
After provider successCharge exists remotelyCan the same operation key recover its result?
After local paid updateRemote charge and local stateWill a duplicate job recognize completion?
After refund requestRefund may exist remotelyDoes refund have its own stable operation identity?

Idempotent means repeating the same logical operation has the intended effect once. It does not mean a worker can never execute twice. Use database uniqueness, conditional state changes, and provider guarantees together where appropriate.

What tests make these answers convincing?

Build tests around observable outcomes. For queries, render several orders with different customers and inspect repeated SQL. For transactions, force the second write to fail and query the database from a clean read path. For callbacks, verify that rollback does not produce the committed side effect.

For jobs, execute the same logical operation twice and inject a failure after the external effect but before the local success marker. A fake provider can model a persisted idempotency key and return the original result. This tests a meaningful failure window without requiring a real charge.

Be careful with test transaction wrappers. A test that never performs a real commit may not exercise commit-dependent behavior in the way you expect. Separate fast unit tests of job logic from integration tests of transaction and queue behavior, using the project's actual adapter where that distinction matters.

How should you practice these Rails questions?

Take one checkout implementation and explain its contract in five sentences: how records are fetched, what is atomic, when jobs become visible, how retries behave, and how you prove the result. Then add the three failure cases from this guide: repeated association reads, a failed reservation, and a worker crash after payment.

Prepare a concise answer first, then expand when asked. “I would preload customers, protect stock with a database invariant, commit the order before dependent work, and reuse the same payment operation key on retries” gives the interviewer clear points to explore. The detailed tests show you can turn that explanation into working software.

Sources and Further Reading

Research checked September 24, 2026. Examples illustrate design reasoning; verify transaction and enqueue behavior against your Rails version and queue adapter.