DEV Community

Alex Aslam
Alex Aslam

Posted on

CRUD Lies: Hidden Pitfalls of Simple Updates

"Your database is lying to you—and you don’t even know it."

CRUD (Create, Read, Update, Delete) seems straightforward:
user.update!(name: "Alice") – clean, simple, atomic.

But beneath the surface, naive updates hide dangerous traps:

  • Lost data from race conditions
  • No audit trails for critical changes
  • Hidden coupling between features

Here’s what really happens when you trust UPDATE too much—and how to fix it.


1. The Illusion of Atomicity

The Lie

"UPDATE operations are atomic and safe."

The Reality

Race conditions lurk in read-modify-write cycles:

# Problem: Two threads read "balance = 100"
balance = user.balance          # Thread A: reads 100
                                # Thread B: reads 100
user.update!(balance: balance + 50)  # Thread A: writes 150
user.update!(balance: balance + 30)  # Thread B: writes 130 (💥 overwrites A!)
Enter fullscreen mode Exit fullscreen mode

Result: Lost update. Final balance should be 180, but it’s 130.

The Fix

  • Database locks:
  user.with_lock do
    user.update!(balance: user.balance + 50)
  end
Enter fullscreen mode Exit fullscreen mode
  • Optimistic concurrency:
  user.update!(balance: user.balance + 30, version: user.version)
  # Fails if `version` changed
Enter fullscreen mode Exit fullscreen mode

2. The Vanishing History Problem

The Lie

"We can always check logs if something goes wrong."

The Reality

Most apps don’t log data changes, and even when they do:

  • Logs expire.
  • Correlations across tables are painful.

Example:

UPDATE orders SET status = 'refunded' WHERE id = 123;
-- Who triggered this? Why? When?
Enter fullscreen mode Exit fullscreen mode

The Fix

  • Event-driven sidecars:
  after_update :log_change

  def log_change
    AuditLog.create!(
      action: "update",
      old_values: previous_changes,
      user: Current.user
    )
  end
Enter fullscreen mode Exit fullscreen mode
  • Change Data Capture (CDC): Tools like Debezium stream DB changes to Kafka.

3. The Dependency Time Bomb

The Lie

"This column only affects one feature."

The Reality

A "simple" UPDATE can trigger unexpected side effects:

class User < ApplicationRecord
  after_update :send_welcome_email, if: -> { saved_change_to_status?(to: "active") }
  after_update :update_search_index
  after_update :notify_admin
end

# Later...
user.update!(status: "active")  # 💥 Fires 3 callbacks, 2 external API calls
Enter fullscreen mode Exit fullscreen mode

The Fix

  • Explicit workflows: Replace callbacks with services.
  UserActivator.new(user).call  # Clearly does X, Y, Z
Enter fullscreen mode Exit fullscreen mode
  • Event-driven decoupling:
  user.update!(status: "active")
  EventBus.publish(UserActivated.new(user_id: user.id))
Enter fullscreen mode Exit fullscreen mode

4. The Phantom Update Problem

The Lie

"UPDATE only touches the columns you specify."

The Reality

ActiveRecord (and others) silently update all changed columns:

user.name = "Alice"
user.save!  # Updates ALL dirty fields, not just `name`
Enter fullscreen mode Exit fullscreen mode

Danger: If another thread changes user.email concurrently, your save! overwrites it.

The Fix

  • Partial updates:
  user.update_columns(name: "Alice")  # Skips callbacks, touches only `name`
Enter fullscreen mode Exit fullscreen mode
  • Strict column lists:
  user.assign_attributes(name: "Alice")
  user.save(only: [:name])
Enter fullscreen mode Exit fullscreen mode

5. When CRUD Is the Right Tool

Simple admin dashboards
Internal tools with no audit needs
Early-stage prototypes

Rule of Thumb:

Use CRUD when "last write wins" is acceptable.


"But CRUD Is So Easy!"

It is—until it isn’t. Start small:

  1. Add auditing to one critical model.
  2. Replace one callback with a service.
  3. Measure the pain before going all-in on events.

Have you been burned by naive updates? Share your horror stories below.

Top comments (0)