The one-character difference that separates silent failures from loud exceptions – and why you need to care.
What’s the Difference?
Rails Active Record methods come in pairs. The non-bang version (save, create, update) returns a falsy value or nil on failure – silently. The bang version (save!, create!, update!) raises an exception on failure – loudly.
# Non-bang: fails silentlyuser = User.new(email: nil)user.save # => false (email validation failed, no exception)# Bang: fails loudlyuser.save! # => raises ActiveRecord::RecordInvalid # "Validation failed: Email can't be blank"
One character. Completely different failure contract.
Core Method Pairs
save / save!
The most common pair you’ll reach for.
# Non-bang: returns true/falsedef update_profile(user, params) if user.update(params) render json: { status: :ok } else render json: { errors: user.errors.full_messages }, status: :unprocessable_entity endend# Bang: raises on failure - use inside transactions or background jobsdef sync_user_data!(user, external_data) user.update!( name: external_data[:name], synced_at: Time.current )end
create / create!
# Non-bang: returns the object regardless - even if invalid!user = User.create(email: "bad")user.persisted? # => false - object was NOT saveduser.errors.full_messages # => ["Email is invalid"]# Bang: raises ActiveRecord::RecordInvalid immediatelyuser = User.create!(email: "bad")# => ActiveRecord::RecordInvalid: Validation failed: Email is invalid
Common trap: Developers check user.id.present? after create – prefer user.persisted? instead.
update / update!
# Non-bang: returns true/falseorder.update(status: "shipped") # => true or false# Bang: raises ActiveRecord::RecordInvalidorder.update!(status: "shipped") # raises if validations fail
destroy / destroy!
# Non-bang: returns the frozen object (even if callbacks prevented destroy)user.destroy # => returns user object, check user.destroyed? to verify# Bang: raises ActiveRecord::RecordNotDestroyed if a before_destroy callback halts ituser.destroy! # => raises if any callback returns false/throws :abort
Finder Method Pairs
find_by / find_by!
# Non-bang: returns nil if not founduser = User.find_by(email: "alice@example.com")return if user.nil? # handle the nil case# Bang: raises ActiveRecord::RecordNotFounduser = User.find_by!(email: "alice@example.com")# No nil check needed - exception propagates (caught by rescue_from in controllers)
find (no bang) already raises RecordNotFound – it’s the original “loud” finder. find_by was the “soft” alternative added later.
first_or_create / first_or_create!
# Non-bang: silently returns invalid object if creation failstag = Tag.where(name: "rails").first_or_create(color: nil) # may return unpersisted record# Bang: raises if creation fails validationstag = Tag.where(name: "rails").first_or_create!(color: "#CC0000")
create_or_find_by / create_or_find_by! (Rails 6+)
Handles race conditions in concurrent environments – tries to create, rescues the unique constraint violation, then finds.
# Non-bangsubscription = Subscription.create_or_find_by(user: current_user, plan: "pro")# Bang - raises if found record doesn't exist and creation fails for non-uniqueness reasonssubscription = Subscription.create_or_find_by!(user: current_user, plan: "pro")
find_sole_by (Rails 7+, available in Rails 8)
# Raises ActiveRecord::RecordNotFound if zero found# Raises ActiveRecord::SoleRecordExceeded if more than one foundadmin = User.find_sole_by(role: "super_admin")
No non-bang counterpart – this is intentionally strict. It’s the “I expect exactly one” finder.
Bulk Insert Pairs (Rails 6+, fully stable in Rails 8)
These skip callbacks and validations by design – the bang variant enforces uniqueness constraints at the DB level.
# insert_all: ignores conflicts (duplicate unique keys are skipped)Product.insert_all([ { sku: "ABC", name: "Widget" }, { sku: "ABC", name: "Duplicate" } # silently skipped])# insert_all!: raises ActiveRecord::RecordNotUnique on conflictsProduct.insert_all!([ { sku: "ABC", name: "Widget" }, { sku: "ABC", name: "Duplicate" } # => raises!])# upsert_all: updates on conflict (no bang needed for this use case)Product.upsert_all( [{ sku: "ABC", name: "Updated Widget" }], unique_by: :sku)
When to Use Each – Real Developer Scenarios
Use non-bang when:
1. Handling user input in controllers – you want to return validation errors, not crash:
def create @post = Post.new(post_params) if @post.save redirect_to @post, notice: "Post created" else render :new, status: :unprocessable_entity # show errors to user endend
2. Conditional logic where failure is expected:
# Trying to "claim" a record in a concurrent systemif order.update(claimed_by: current_user.id, claimed_at: Time.current) process_order(order)else redirect_to orders_path, alert: "Order already claimed"end
3. Soft lookups where nil is a valid state:
promo = Promotion.find_by(code: params[:promo_code])discount = promo&.discount_percentage || 0
Use bang when:
1. Inside database transactions – a failure must roll back everything:
ActiveRecord::Base.transaction do order.update!(status: "processing") inventory.update!(quantity: inventory.quantity - order.quantity) payment.update!(charged_at: Time.current) # If any of these fail, the whole transaction rolls backend
2. In background jobs / service objects – let the exception bubble up to the job framework (Sidekiq, Solid Queue in Rails 8) for retry logic:
class SyncUserJob < ApplicationJob def perform(user_id) user = User.find(user_id) # raises if not found user.update!(synced_at: Time.current) # raises if validation fails # Sidekiq/Solid Queue catches the exception and retries endend
3. Seeds and data migrations – you want loud failures, not silent data corruption:
# db/seeds.rbUser.create!(email: "admin@example.com", role: "admin", password: "SecurePass123!")
4. In service objects where the caller handles errors:
class ChargeCustomer def call(user, amount) payment = Payment.create!(user: user, amount: amount, status: "pending") result = StripeGateway.charge(user.stripe_id, amount) payment.update!(status: "completed", stripe_id: result.id) payment rescue ActiveRecord::RecordInvalid => e Rails.logger.error("Payment record invalid: #{e.message}") raise # re-raise for the controller to handle endend
Quick Reference
| Method | On Failure | Returns |
|---|---|---|
save | false | true/false |
save! | Raises RecordInvalid | true |
create | Returns unpersisted object | ActiveRecord object |
create! | Raises RecordInvalid | Persisted object |
update | false | true/false |
update! | Raises RecordInvalid | true |
destroy | Returns frozen object | Object (check .destroyed?) |
destroy! | Raises RecordNotDestroyed | Object |
find_by | nil | Object or nil |
find_by! | Raises RecordNotFound | Object |
insert_all | Skips conflicts | ActiveRecord::Result |
insert_all! | Raises RecordNotUnique | ActiveRecord::Result |
The Mental Model
Think of it as a contract:
- Non-bang – “Try this, and I’ll tell you if it worked.” You own the failure-handling logic.
- Bang – “Do this, and if it can’t be done, raise hell.” The exception propagates to whoever is responsible for it.
A practical rule of thumb: use bang in service layers and jobs; use non-bang at the boundary where user input meets your system (controllers, form objects).
# Controller boundary - non-bangdef update if @user.update(user_params) ... endend# Service layer - bangclass UserProfileService def sync!(user) user.update!(last_synced: Time.current) # let it raise, job will retry endend
With Rails 8’s Solid Queue as the default job backend, leaning into bang methods in your jobs is even more natural – the exception bubbles up, gets logged, and the job retries based on your retry_on configuration. Silent failures in async contexts are the hardest bugs to track down.
Choose your contract deliberately. Every save without checking its return value is a bug waiting to happen.